nrdocs 0.2.1 → 0.2.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.
@@ -0,0 +1,2458 @@
1
+ // ../worker/src/router.ts
2
+ function buildRoute(method, path, handler) {
3
+ const paramNames = [];
4
+ const regexStr = path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_match, name) => {
5
+ paramNames.push(name);
6
+ return "([^/]+)";
7
+ });
8
+ const pattern = new RegExp(`^${regexStr}$`);
9
+ return { method, pattern, paramNames, handler };
10
+ }
11
+ function matchRoute(routes, method, pathname) {
12
+ for (const route of routes) {
13
+ if (route.method !== method) continue;
14
+ const match = route.pattern.exec(pathname);
15
+ if (match) {
16
+ const params = {};
17
+ for (let i = 0; i < route.paramNames.length; i++) {
18
+ const name = route.paramNames[i];
19
+ params[name] = decodeURIComponent(match[i + 1]);
20
+ }
21
+ return { handler: route.handler, params };
22
+ }
23
+ }
24
+ return null;
25
+ }
26
+ var Router = class {
27
+ routes = [];
28
+ get(path, handler) {
29
+ this.routes.push(buildRoute("GET", path, handler));
30
+ }
31
+ post(path, handler) {
32
+ this.routes.push(buildRoute("POST", path, handler));
33
+ }
34
+ put(path, handler) {
35
+ this.routes.push(buildRoute("PUT", path, handler));
36
+ }
37
+ delete(path, handler) {
38
+ this.routes.push(buildRoute("DELETE", path, handler));
39
+ }
40
+ async handle(request, env) {
41
+ const url = new URL(request.url);
42
+ const result = matchRoute(this.routes, request.method, url.pathname);
43
+ if (!result) return null;
44
+ return result.handler(request, env, result.params);
45
+ }
46
+ };
47
+
48
+ // ../worker/src/responses.ts
49
+ function jsonSuccess(data, status = 200) {
50
+ return Response.json({ ok: true, data }, { status });
51
+ }
52
+ function jsonError(code, message, status, details) {
53
+ const body = {
54
+ ok: false,
55
+ error: { code, message }
56
+ };
57
+ if (details) {
58
+ body.error.details = details;
59
+ }
60
+ return Response.json(body, { status });
61
+ }
62
+
63
+ // ../shared/dist/constants.js
64
+ var NRDOCS_VERSION = "0.1.0";
65
+ var ACCESS_MODES = ["none", "public", "password"];
66
+ var DEFAULT_MAX_ARCHIVE_SIZE_MB = 50;
67
+ var DEFAULT_MAX_FILE_COUNT = 5e3;
68
+ var DEFAULT_MAX_EXTRACTED_SIZE_MB = 200;
69
+ var DEFAULT_MAX_SINGLE_FILE_SIZE_MB = 25;
70
+ var DEFAULT_MIN_PASSWORD_LENGTH = 8;
71
+ var DEFAULT_MAX_PASSWORD_LENGTH = 128;
72
+ var DEFAULT_PBKDF2_ITERATIONS = 1e5;
73
+ var ALLOWED_STATIC_KEYS = ["homepage", "favicon", "robots"];
74
+ var ALLOWED_ASSET_EXTENSIONS = /* @__PURE__ */ new Set([
75
+ ".html",
76
+ ".css",
77
+ ".json",
78
+ ".svg",
79
+ ".png",
80
+ ".jpg",
81
+ ".jpeg",
82
+ ".gif",
83
+ ".webp",
84
+ ".ico",
85
+ ".txt",
86
+ ".pdf"
87
+ ]);
88
+ var REJECTED_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".mjs", ".cjs"]);
89
+
90
+ // ../worker/src/auth.ts
91
+ function requireOperator(request, env) {
92
+ const authHeader = request.headers.get("Authorization");
93
+ if (!authHeader) {
94
+ return {
95
+ authenticated: false,
96
+ response: jsonError("UNAUTHORIZED", "Missing Authorization header", 401)
97
+ };
98
+ }
99
+ const parts = authHeader.split(" ");
100
+ if (parts.length !== 2 || parts[0] !== "Bearer") {
101
+ return {
102
+ authenticated: false,
103
+ response: jsonError("UNAUTHORIZED", "Invalid Authorization header format", 401)
104
+ };
105
+ }
106
+ const token = parts[1];
107
+ if (!timingSafeEqual(token, env.OPERATOR_TOKEN)) {
108
+ return {
109
+ authenticated: false,
110
+ response: jsonError("UNAUTHORIZED", "Invalid operator token", 401)
111
+ };
112
+ }
113
+ return { authenticated: true };
114
+ }
115
+ function timingSafeEqual(a, b) {
116
+ if (a.length !== b.length) {
117
+ const dummy = a;
118
+ let result2 = 1;
119
+ for (let i = 0; i < dummy.length; i++) {
120
+ result2 |= dummy.charCodeAt(i) ^ dummy.charCodeAt(i);
121
+ }
122
+ void result2;
123
+ return false;
124
+ }
125
+ let result = 0;
126
+ for (let i = 0; i < a.length; i++) {
127
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
128
+ }
129
+ return result === 0;
130
+ }
131
+
132
+ // ../worker/src/handlers/status.ts
133
+ async function handleStatus(request, env, _params) {
134
+ const basicInfo = {
135
+ service: "nrdocs",
136
+ version: NRDOCS_VERSION,
137
+ base_url: env.BASE_URL
138
+ };
139
+ const auth = requireOperator(request, env);
140
+ if (auth.authenticated) {
141
+ return jsonSuccess({
142
+ ...basicInfo,
143
+ authenticated: true
144
+ });
145
+ }
146
+ return jsonSuccess(basicInfo);
147
+ }
148
+
149
+ // ../worker/src/handlers/operator-me.ts
150
+ async function handleOperatorMe(request, env, _params) {
151
+ const auth = requireOperator(request, env);
152
+ if (!auth.authenticated) return auth.response;
153
+ return jsonSuccess({
154
+ operator: {
155
+ type: "operator_token"
156
+ },
157
+ deployment: {
158
+ base_url: env.BASE_URL,
159
+ version: NRDOCS_VERSION
160
+ }
161
+ });
162
+ }
163
+
164
+ // ../worker/src/crypto.ts
165
+ async function hashPassword(password, iterations) {
166
+ const iterationCount = iterations ?? DEFAULT_PBKDF2_ITERATIONS;
167
+ const saltBytes = new Uint8Array(16);
168
+ crypto.getRandomValues(saltBytes);
169
+ const encoder = new TextEncoder();
170
+ const passwordBytes = encoder.encode(password);
171
+ const keyMaterial = await crypto.subtle.importKey(
172
+ "raw",
173
+ passwordBytes,
174
+ "PBKDF2",
175
+ false,
176
+ ["deriveBits"]
177
+ );
178
+ const derivedBits = await crypto.subtle.deriveBits(
179
+ {
180
+ name: "PBKDF2",
181
+ salt: saltBytes,
182
+ iterations: iterationCount,
183
+ hash: "SHA-256"
184
+ },
185
+ keyMaterial,
186
+ 256
187
+ );
188
+ return {
189
+ hash: bytesToHex(new Uint8Array(derivedBits)),
190
+ salt: bytesToHex(saltBytes),
191
+ iteration_count: iterationCount
192
+ };
193
+ }
194
+ async function verifyPassword(password, hash, salt, iterationCount) {
195
+ const encoder = new TextEncoder();
196
+ const passwordBytes = encoder.encode(password);
197
+ const saltBytes = hexToBytes(salt);
198
+ const keyMaterial = await crypto.subtle.importKey(
199
+ "raw",
200
+ passwordBytes,
201
+ "PBKDF2",
202
+ false,
203
+ ["deriveBits"]
204
+ );
205
+ const derivedBits = await crypto.subtle.deriveBits(
206
+ {
207
+ name: "PBKDF2",
208
+ salt: saltBytes,
209
+ iterations: iterationCount,
210
+ hash: "SHA-256"
211
+ },
212
+ keyMaterial,
213
+ 256
214
+ );
215
+ const derivedHex = bytesToHex(new Uint8Array(derivedBits));
216
+ return constantTimeEqual(derivedHex, hash);
217
+ }
218
+ function bytesToHex(bytes) {
219
+ let hex = "";
220
+ for (let i = 0; i < bytes.length; i++) {
221
+ hex += bytes[i].toString(16).padStart(2, "0");
222
+ }
223
+ return hex;
224
+ }
225
+ function hexToBytes(hex) {
226
+ const bytes = new Uint8Array(hex.length / 2);
227
+ for (let i = 0; i < bytes.length; i++) {
228
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
229
+ }
230
+ return bytes;
231
+ }
232
+ function constantTimeEqual(a, b) {
233
+ if (a.length !== b.length) return false;
234
+ let result = 0;
235
+ for (let i = 0; i < a.length; i++) {
236
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
237
+ }
238
+ return result === 0;
239
+ }
240
+
241
+ // ../worker/src/db/id.ts
242
+ function generateId(prefix) {
243
+ return `${prefix}${crypto.randomUUID().replace(/-/g, "")}`;
244
+ }
245
+
246
+ // ../worker/src/db/repos.ts
247
+ function normalizeRepo(row) {
248
+ return {
249
+ ...row,
250
+ allow_repo_owner_password: row["allow_repo_owner_password"] === 1
251
+ };
252
+ }
253
+ async function findRepoByFullName(db, fullName) {
254
+ const row = await db.prepare("SELECT * FROM repos WHERE full_name = ?").bind(fullName.toLowerCase()).first();
255
+ return row ? normalizeRepo(row) : null;
256
+ }
257
+ async function findRepoByGithubId(db, githubRepoId) {
258
+ const row = await db.prepare("SELECT * FROM repos WHERE github_repository_id = ?").bind(githubRepoId).first();
259
+ return row ? normalizeRepo(row) : null;
260
+ }
261
+ async function upsertRepo(db, input) {
262
+ const now = (/* @__PURE__ */ new Date()).toISOString();
263
+ const owner = input.owner.toLowerCase();
264
+ const name = input.name.toLowerCase();
265
+ const fullName = input.full_name.toLowerCase();
266
+ const existing = await findRepoByGithubId(db, input.github_repository_id);
267
+ if (existing) {
268
+ await db.prepare(
269
+ `UPDATE repos SET owner = ?, name = ?, full_name = ?, site_title = COALESCE(?, site_title), requested_access = COALESCE(?, requested_access), updated_at = ? WHERE id = ?`
270
+ ).bind(
271
+ owner,
272
+ name,
273
+ fullName,
274
+ input.site_title ?? null,
275
+ input.requested_access ?? null,
276
+ now,
277
+ existing.id
278
+ ).run();
279
+ const updated = await findRepoByGithubId(db, input.github_repository_id);
280
+ return updated;
281
+ }
282
+ const id = generateId("repo_");
283
+ await db.prepare(
284
+ `INSERT INTO repos (id, github_repository_id, owner, name, full_name, approval_state, access_mode, allow_repo_owner_password, site_title, requested_access, created_at, updated_at)
285
+ VALUES (?, ?, ?, ?, ?, 'pending', 'none', ?, ?, ?, ?, ?)`
286
+ ).bind(
287
+ id,
288
+ input.github_repository_id,
289
+ owner,
290
+ name,
291
+ fullName,
292
+ input.allow_repo_owner_password === true ? 1 : 0,
293
+ input.site_title ?? null,
294
+ input.requested_access ?? null,
295
+ now,
296
+ now
297
+ ).run();
298
+ const created = await findRepoByGithubId(db, input.github_repository_id);
299
+ return created;
300
+ }
301
+ async function approveRepo(db, repoId, accessMode, approvedBy) {
302
+ const now = (/* @__PURE__ */ new Date()).toISOString();
303
+ await db.prepare(
304
+ `UPDATE repos SET approval_state = 'approved', access_mode = ?, approved_at = ?, approved_by = ?, disabled_at = NULL, disabled_by = NULL, updated_at = ? WHERE id = ?`
305
+ ).bind(accessMode, now, approvedBy, now, repoId).run();
306
+ const row = await db.prepare("SELECT * FROM repos WHERE id = ?").bind(repoId).first();
307
+ return normalizeRepo(row);
308
+ }
309
+ async function disableRepo(db, repoId, disabledBy, reason) {
310
+ const now = (/* @__PURE__ */ new Date()).toISOString();
311
+ await db.prepare(
312
+ `UPDATE repos SET approval_state = 'disabled', access_mode = 'none', disabled_at = ?, disabled_by = ?, updated_at = ? WHERE id = ?`
313
+ ).bind(now, disabledBy, now, repoId).run();
314
+ void reason;
315
+ const row = await db.prepare("SELECT * FROM repos WHERE id = ?").bind(repoId).first();
316
+ return normalizeRepo(row);
317
+ }
318
+ async function setAccessMode(db, repoId, accessMode) {
319
+ const now = (/* @__PURE__ */ new Date()).toISOString();
320
+ await db.prepare(`UPDATE repos SET access_mode = ?, updated_at = ? WHERE id = ?`).bind(accessMode, now, repoId).run();
321
+ const row = await db.prepare("SELECT * FROM repos WHERE id = ?").bind(repoId).first();
322
+ return normalizeRepo(row);
323
+ }
324
+ async function setSelfPasswordAllowFlag(db, repoId, allow) {
325
+ const now = (/* @__PURE__ */ new Date()).toISOString();
326
+ await db.prepare(
327
+ `UPDATE repos SET allow_repo_owner_password = ?, updated_at = ? WHERE id = ?`
328
+ ).bind(allow ? 1 : 0, now, repoId).run();
329
+ }
330
+ async function updateLatestBuild(db, repoId, buildId) {
331
+ const now = (/* @__PURE__ */ new Date()).toISOString();
332
+ await db.prepare(
333
+ `UPDATE repos SET latest_successful_build_id = ?, updated_at = ? WHERE id = ?`
334
+ ).bind(buildId, now, repoId).run();
335
+ }
336
+ async function listRepos(db, filters) {
337
+ const conditions = [];
338
+ const bindings = [];
339
+ if (filters?.state) {
340
+ conditions.push("approval_state = ?");
341
+ bindings.push(filters.state);
342
+ }
343
+ if (filters?.access) {
344
+ conditions.push("access_mode = ?");
345
+ bindings.push(filters.access);
346
+ }
347
+ if (filters?.owner) {
348
+ conditions.push("owner = ?");
349
+ bindings.push(filters.owner.toLowerCase());
350
+ }
351
+ if (filters?.cursor) {
352
+ conditions.push("id > ?");
353
+ bindings.push(filters.cursor);
354
+ }
355
+ const limit = filters?.limit ?? 50;
356
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
357
+ const query = `SELECT * FROM repos ${where} ORDER BY id ASC LIMIT ?`;
358
+ bindings.push(limit + 1);
359
+ const stmt = db.prepare(query);
360
+ const result = await stmt.bind(...bindings).all();
361
+ const rows = (result.results ?? []).map(normalizeRepo);
362
+ let nextCursor = null;
363
+ if (rows.length > limit) {
364
+ rows.pop();
365
+ nextCursor = rows[rows.length - 1]?.id ?? null;
366
+ }
367
+ return { repos: rows, next_cursor: nextCursor };
368
+ }
369
+
370
+ // ../worker/src/db/builds.ts
371
+ async function createBuild(db, input) {
372
+ const id = generateId("build_");
373
+ const now = (/* @__PURE__ */ new Date()).toISOString();
374
+ await db.prepare(
375
+ `INSERT INTO builds (id, repo_id, github_repository_id, git_sha, git_ref, workflow_ref, run_id, status, created_at)
376
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'uploading', ?)`
377
+ ).bind(
378
+ id,
379
+ input.repo_id,
380
+ input.github_repository_id,
381
+ input.git_sha,
382
+ input.git_ref ?? null,
383
+ input.workflow_ref ?? null,
384
+ input.run_id ?? null,
385
+ now
386
+ ).run();
387
+ const build = await db.prepare("SELECT * FROM builds WHERE id = ?").bind(id).first();
388
+ return build;
389
+ }
390
+ async function markBuildSuccess(db, buildId, artifactPrefix, sizeBytes, fileCount, contentHash) {
391
+ const now = (/* @__PURE__ */ new Date()).toISOString();
392
+ await db.prepare(
393
+ `UPDATE builds SET status = 'success', artifact_prefix = ?, artifact_size_bytes = ?, file_count = ?, content_hash = ?, completed_at = ? WHERE id = ?`
394
+ ).bind(artifactPrefix, sizeBytes, fileCount, contentHash, now, buildId).run();
395
+ const build = await db.prepare("SELECT * FROM builds WHERE id = ?").bind(buildId).first();
396
+ return build;
397
+ }
398
+ async function markBuildFailed(db, buildId, errorCode, errorMessage) {
399
+ const now = (/* @__PURE__ */ new Date()).toISOString();
400
+ await db.prepare(
401
+ `UPDATE builds SET status = 'failed', error_code = ?, error_message = ?, completed_at = ? WHERE id = ?`
402
+ ).bind(errorCode, errorMessage, now, buildId).run();
403
+ const build = await db.prepare("SELECT * FROM builds WHERE id = ?").bind(buildId).first();
404
+ return build;
405
+ }
406
+ async function findBuildById(db, buildId) {
407
+ const result = await db.prepare("SELECT * FROM builds WHERE id = ?").bind(buildId).first();
408
+ return result ?? null;
409
+ }
410
+
411
+ // ../worker/src/db/rules.ts
412
+ function normalizeRule(row) {
413
+ return {
414
+ ...row,
415
+ enabled: row["enabled"] === 1 || row["enabled"] === true,
416
+ default_allow_repo_owner_password: row["default_allow_repo_owner_password"] === 1 || row["default_allow_repo_owner_password"] === true
417
+ };
418
+ }
419
+ async function createRule(db, pattern, accessMode, createdBy, priority, defaultAllowSelfPassword = true) {
420
+ const id = generateId("rule_");
421
+ const now = (/* @__PURE__ */ new Date()).toISOString();
422
+ await db.prepare(
423
+ `INSERT INTO auto_approval_rules (id, pattern, access_mode, enabled, priority, default_allow_repo_owner_password, created_at, created_by, updated_at, updated_by)
424
+ VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?)`
425
+ ).bind(
426
+ id,
427
+ pattern.toLowerCase(),
428
+ accessMode,
429
+ priority ?? 0,
430
+ defaultAllowSelfPassword ? 1 : 0,
431
+ now,
432
+ createdBy,
433
+ now,
434
+ createdBy
435
+ ).run();
436
+ const row = await db.prepare("SELECT * FROM auto_approval_rules WHERE id = ?").bind(id).first();
437
+ return normalizeRule(row);
438
+ }
439
+ async function deleteRule(db, ruleId) {
440
+ const result = await db.prepare("DELETE FROM auto_approval_rules WHERE id = ?").bind(ruleId).run();
441
+ return (result.meta?.changes ?? 0) > 0;
442
+ }
443
+ async function listRules(db) {
444
+ const result = await db.prepare("SELECT * FROM auto_approval_rules WHERE enabled = 1 ORDER BY priority DESC, created_at ASC").all();
445
+ return (result.results ?? []).map(normalizeRule);
446
+ }
447
+ function findMatchingRule(rules, fullName) {
448
+ const normalized = fullName.toLowerCase();
449
+ const owner = normalized.split("/")[0];
450
+ const exactMatches = [];
451
+ const namespaceMatches = [];
452
+ for (const rule of rules) {
453
+ if (!rule.enabled) continue;
454
+ const pattern = rule.pattern.toLowerCase();
455
+ if (pattern === normalized) {
456
+ exactMatches.push(rule);
457
+ } else if (pattern === `${owner}/*`) {
458
+ namespaceMatches.push(rule);
459
+ }
460
+ }
461
+ const candidates = exactMatches.length > 0 ? exactMatches : namespaceMatches;
462
+ if (candidates.length === 0) return null;
463
+ candidates.sort((a, b) => {
464
+ if (b.priority !== a.priority) return b.priority - a.priority;
465
+ return a.created_at.localeCompare(b.created_at);
466
+ });
467
+ return candidates[0];
468
+ }
469
+ async function matchRules(db, fullName) {
470
+ const rules = await listRules(db);
471
+ return findMatchingRule(rules, fullName);
472
+ }
473
+
474
+ // ../worker/src/db/passwords.ts
475
+ async function setPassword(db, repoId, hash, salt, iterationCount, updatedBy) {
476
+ const now = (/* @__PURE__ */ new Date()).toISOString();
477
+ await db.prepare(
478
+ `UPDATE password_credentials SET active = 0, updated_at = ?, updated_by = ? WHERE repo_id = ? AND active = 1`
479
+ ).bind(now, updatedBy, repoId).run();
480
+ const latest = await db.prepare(
481
+ `SELECT MAX(password_version) as max_version FROM password_credentials WHERE repo_id = ?`
482
+ ).bind(repoId).first();
483
+ const nextVersion = (latest?.max_version ?? 0) + 1;
484
+ const id = generateId("cred_");
485
+ await db.prepare(
486
+ `INSERT INTO password_credentials (id, repo_id, password_hash, hash_algorithm, salt, iteration_count, password_version, active, created_at, updated_at, updated_by)
487
+ VALUES (?, ?, ?, 'pbkdf2-sha256', ?, ?, ?, 1, ?, ?, ?)`
488
+ ).bind(id, repoId, hash, salt, iterationCount, nextVersion, now, now, updatedBy).run();
489
+ const cred = await db.prepare("SELECT * FROM password_credentials WHERE id = ?").bind(id).first();
490
+ return cred;
491
+ }
492
+ async function getActivePassword(db, repoId) {
493
+ const result = await db.prepare(
494
+ "SELECT * FROM password_credentials WHERE repo_id = ? AND active = 1 ORDER BY password_version DESC LIMIT 1"
495
+ ).bind(repoId).first();
496
+ return result ?? null;
497
+ }
498
+ async function hasPassword(db, repoId) {
499
+ const result = await db.prepare(
500
+ "SELECT COUNT(*) as cnt FROM password_credentials WHERE repo_id = ? AND active = 1"
501
+ ).bind(repoId).first();
502
+ return (result?.cnt ?? 0) > 0;
503
+ }
504
+ async function nextPasswordVersion(db, repoId) {
505
+ const latest = await db.prepare(
506
+ `SELECT MAX(password_version) as max_version FROM password_credentials WHERE repo_id = ?`
507
+ ).bind(repoId).first();
508
+ return (latest?.max_version ?? 0) + 1;
509
+ }
510
+ async function storeSelfServicePassword(db, args) {
511
+ const { hash, salt, iteration_count } = await hashPassword(args.plaintext);
512
+ const credId = generateId("cred_");
513
+ const auditCredId = generateId("evt_");
514
+ const now = (/* @__PURE__ */ new Date()).toISOString();
515
+ const version = await nextPasswordVersion(db, args.repo.id);
516
+ const flipToPassword = args.repo.approval_state === "approved" && args.repo.access_mode === "none";
517
+ const stmts = [];
518
+ stmts.push(
519
+ db.prepare(
520
+ `UPDATE password_credentials SET active = 0, updated_at = ?, updated_by = ? WHERE repo_id = ? AND active = 1`
521
+ ).bind(now, "repo_owner", args.repo.id)
522
+ );
523
+ stmts.push(
524
+ db.prepare(
525
+ `INSERT INTO password_credentials (id, repo_id, password_hash, hash_algorithm, salt, iteration_count, password_version, active, created_at, updated_at, updated_by)
526
+ VALUES (?, ?, ?, 'pbkdf2-sha256', ?, ?, ?, 1, ?, ?, ?)`
527
+ ).bind(
528
+ credId,
529
+ args.repo.id,
530
+ hash,
531
+ salt,
532
+ iteration_count,
533
+ version,
534
+ now,
535
+ now,
536
+ "repo_owner"
537
+ )
538
+ );
539
+ stmts.push(
540
+ db.prepare(
541
+ `INSERT INTO audit_log (id, event_type, actor_type, actor_id, repo_id, build_id, rule_id, metadata_json, created_at)
542
+ VALUES (?, 'repo.self_password_set', 'github_action', ?, ?, ?, NULL, NULL, ?)`
543
+ ).bind(auditCredId, args.fullName, args.repo.id, args.buildId, now)
544
+ );
545
+ if (flipToPassword) {
546
+ const auditModeId = generateId("evt_");
547
+ stmts.push(
548
+ db.prepare(
549
+ `UPDATE repos SET access_mode = 'password', updated_at = ? WHERE id = ?`
550
+ ).bind(now, args.repo.id)
551
+ );
552
+ stmts.push(
553
+ db.prepare(
554
+ `INSERT INTO audit_log (id, event_type, actor_type, actor_id, repo_id, build_id, rule_id, metadata_json, created_at)
555
+ VALUES (?, 'repo.access_changed', 'github_action', ?, ?, ?, NULL, ?, ?)`
556
+ ).bind(
557
+ auditModeId,
558
+ args.fullName,
559
+ args.repo.id,
560
+ args.buildId,
561
+ JSON.stringify({ old_mode: args.repo.access_mode, new_mode: "password" }),
562
+ now
563
+ )
564
+ );
565
+ }
566
+ try {
567
+ const results = await db.batch(stmts);
568
+ for (const r of results) {
569
+ if (r.error) {
570
+ return { ok: false };
571
+ }
572
+ }
573
+ return { ok: true };
574
+ } catch {
575
+ return { ok: false };
576
+ }
577
+ }
578
+
579
+ // ../worker/src/db/audit.ts
580
+ async function writeAuditEvent(db, event) {
581
+ const id = generateId("evt_");
582
+ const now = (/* @__PURE__ */ new Date()).toISOString();
583
+ const metadataJson = event.metadata ? JSON.stringify(event.metadata) : null;
584
+ await db.prepare(
585
+ `INSERT INTO audit_log (id, event_type, actor_type, actor_id, repo_id, build_id, rule_id, metadata_json, created_at)
586
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
587
+ ).bind(
588
+ id,
589
+ event.event_type,
590
+ event.actor_type,
591
+ event.actor_id ?? null,
592
+ event.repo_id ?? null,
593
+ event.build_id ?? null,
594
+ event.rule_id ?? null,
595
+ metadataJson,
596
+ now
597
+ ).run();
598
+ }
599
+
600
+ // ../worker/src/db/transitions.ts
601
+ function validateApproval(repo) {
602
+ if (!repo.latest_successful_build_id) {
603
+ return {
604
+ valid: false,
605
+ error: "Cannot approve repo without a successful build"
606
+ };
607
+ }
608
+ return { valid: true };
609
+ }
610
+ function validateAccessChange(repo, _newMode) {
611
+ if (repo.approval_state !== "approved") {
612
+ return {
613
+ valid: false,
614
+ error: `Cannot change access mode on a repo in '${repo.approval_state}' state; must be approved`
615
+ };
616
+ }
617
+ return { valid: true };
618
+ }
619
+
620
+ // ../worker/src/handlers/repos.ts
621
+ async function handleListRepos(request, env, _params) {
622
+ const auth = requireOperator(request, env);
623
+ if (!auth.authenticated) return auth.response;
624
+ const url = new URL(request.url);
625
+ const state = url.searchParams.get("state");
626
+ const access = url.searchParams.get("access");
627
+ const owner = url.searchParams.get("owner");
628
+ const limitStr = url.searchParams.get("limit");
629
+ const cursor = url.searchParams.get("cursor");
630
+ const limit = limitStr ? Math.min(Math.max(parseInt(limitStr, 10) || 50, 1), 100) : 50;
631
+ const result = await listRepos(env.DB, {
632
+ state: state ?? void 0,
633
+ access: access ?? void 0,
634
+ owner: owner ?? void 0,
635
+ limit,
636
+ cursor: cursor ?? void 0
637
+ });
638
+ return jsonSuccess({
639
+ repos: result.repos,
640
+ next_cursor: result.next_cursor
641
+ });
642
+ }
643
+ async function handleGetRepo(request, env, params) {
644
+ const auth = requireOperator(request, env);
645
+ if (!auth.authenticated) return auth.response;
646
+ const fullName = `${params["owner"]}/${params["repo"]}`.toLowerCase();
647
+ const repo = await findRepoByFullName(env.DB, fullName);
648
+ if (!repo) {
649
+ return jsonError("NOT_FOUND", `Repository '${fullName}' not found`, 404);
650
+ }
651
+ return jsonSuccess({ repo });
652
+ }
653
+ async function handleApproveRepo(request, env, params) {
654
+ const auth = requireOperator(request, env);
655
+ if (!auth.authenticated) return auth.response;
656
+ const fullName = `${params["owner"]}/${params["repo"]}`.toLowerCase();
657
+ const repo = await findRepoByFullName(env.DB, fullName);
658
+ if (!repo) {
659
+ return jsonError("NOT_FOUND", `Repository '${fullName}' not found`, 404);
660
+ }
661
+ let body;
662
+ try {
663
+ body = await request.json();
664
+ } catch {
665
+ return jsonError("INVALID_BODY", "Request body must be valid JSON", 400);
666
+ }
667
+ if (!body.access_mode || !["public", "password"].includes(body.access_mode)) {
668
+ return jsonError(
669
+ "VALIDATION_ERROR",
670
+ 'access_mode must be "public" or "password"',
671
+ 400,
672
+ { field: "access_mode" }
673
+ );
674
+ }
675
+ const validation = validateApproval(repo);
676
+ if (!validation.valid) {
677
+ return jsonError("INVALID_STATE", validation.error, 409);
678
+ }
679
+ const accessMode = body.access_mode;
680
+ const updated = await approveRepo(env.DB, repo.id, accessMode, "operator");
681
+ await writeAuditEvent(env.DB, {
682
+ event_type: "repo.approved",
683
+ actor_type: "operator",
684
+ repo_id: repo.id,
685
+ metadata: { access_mode: accessMode }
686
+ });
687
+ return jsonSuccess({ repo: updated });
688
+ }
689
+ async function handleDisableRepo(request, env, params) {
690
+ const auth = requireOperator(request, env);
691
+ if (!auth.authenticated) return auth.response;
692
+ const fullName = `${params["owner"]}/${params["repo"]}`.toLowerCase();
693
+ const repo = await findRepoByFullName(env.DB, fullName);
694
+ if (!repo) {
695
+ return jsonError("NOT_FOUND", `Repository '${fullName}' not found`, 404);
696
+ }
697
+ let reason;
698
+ try {
699
+ const body = await request.json();
700
+ reason = body.reason;
701
+ } catch {
702
+ }
703
+ const updated = await disableRepo(env.DB, repo.id, "operator", reason);
704
+ await writeAuditEvent(env.DB, {
705
+ event_type: "repo.disabled",
706
+ actor_type: "operator",
707
+ repo_id: repo.id,
708
+ metadata: reason ? { reason } : void 0
709
+ });
710
+ return jsonSuccess({ repo: updated });
711
+ }
712
+ async function handleSetAccess(request, env, params) {
713
+ const auth = requireOperator(request, env);
714
+ if (!auth.authenticated) return auth.response;
715
+ const fullName = `${params["owner"]}/${params["repo"]}`.toLowerCase();
716
+ const repo = await findRepoByFullName(env.DB, fullName);
717
+ if (!repo) {
718
+ return jsonError("NOT_FOUND", `Repository '${fullName}' not found`, 404);
719
+ }
720
+ let body;
721
+ try {
722
+ body = await request.json();
723
+ } catch {
724
+ return jsonError("INVALID_BODY", "Request body must be valid JSON", 400);
725
+ }
726
+ if (!body.access_mode || !ACCESS_MODES.includes(body.access_mode)) {
727
+ return jsonError(
728
+ "VALIDATION_ERROR",
729
+ `access_mode must be one of: ${ACCESS_MODES.join(", ")}`,
730
+ 400,
731
+ { field: "access_mode" }
732
+ );
733
+ }
734
+ const newMode = body.access_mode;
735
+ const validation = validateAccessChange(repo, newMode);
736
+ if (!validation.valid) {
737
+ return jsonError("INVALID_STATE", validation.error, 409);
738
+ }
739
+ const updated = await setAccessMode(env.DB, repo.id, newMode);
740
+ await writeAuditEvent(env.DB, {
741
+ event_type: "repo.access_changed",
742
+ actor_type: "operator",
743
+ repo_id: repo.id,
744
+ metadata: { old_mode: repo.access_mode, new_mode: newMode }
745
+ });
746
+ return jsonSuccess({ repo: updated });
747
+ }
748
+ async function handleSetPassword(request, env, params) {
749
+ const auth = requireOperator(request, env);
750
+ if (!auth.authenticated) return auth.response;
751
+ const fullName = `${params["owner"]}/${params["repo"]}`.toLowerCase();
752
+ const repo = await findRepoByFullName(env.DB, fullName);
753
+ if (!repo) {
754
+ return jsonError("NOT_FOUND", `Repository '${fullName}' not found`, 404);
755
+ }
756
+ let body;
757
+ try {
758
+ body = await request.json();
759
+ } catch {
760
+ return jsonError("INVALID_BODY", "Request body must be valid JSON", 400);
761
+ }
762
+ if (!body.password || typeof body.password !== "string") {
763
+ return jsonError("VALIDATION_ERROR", "password is required", 400, { field: "password" });
764
+ }
765
+ if (body.password.length < DEFAULT_MIN_PASSWORD_LENGTH) {
766
+ return jsonError(
767
+ "VALIDATION_ERROR",
768
+ `Password must be at least ${DEFAULT_MIN_PASSWORD_LENGTH} characters`,
769
+ 400,
770
+ { field: "password", min_length: DEFAULT_MIN_PASSWORD_LENGTH }
771
+ );
772
+ }
773
+ if (body.password.length > DEFAULT_MAX_PASSWORD_LENGTH) {
774
+ return jsonError(
775
+ "VALIDATION_ERROR",
776
+ `Password must be at most ${DEFAULT_MAX_PASSWORD_LENGTH} characters`,
777
+ 400,
778
+ { field: "password", max_length: DEFAULT_MAX_PASSWORD_LENGTH }
779
+ );
780
+ }
781
+ const { hash, salt, iteration_count } = await hashPassword(body.password);
782
+ await setPassword(env.DB, repo.id, hash, salt, iteration_count, "operator");
783
+ await writeAuditEvent(env.DB, {
784
+ event_type: "repo.password_set",
785
+ actor_type: "operator",
786
+ repo_id: repo.id
787
+ });
788
+ return jsonSuccess({ message: "Password updated successfully" });
789
+ }
790
+ async function handleAllowSelfPassword(request, env, params) {
791
+ const auth = requireOperator(request, env);
792
+ if (!auth.authenticated) return auth.response;
793
+ const fullName = `${params["owner"]}/${params["repo"]}`.toLowerCase();
794
+ const repo = await findRepoByFullName(env.DB, fullName);
795
+ if (!repo) {
796
+ return jsonError("NOT_FOUND", `Repository '${fullName}' not found`, 404);
797
+ }
798
+ await setSelfPasswordAllowFlag(env.DB, repo.id, true);
799
+ await writeAuditEvent(env.DB, {
800
+ event_type: "repo.self_password_allowed",
801
+ actor_type: "operator",
802
+ repo_id: repo.id
803
+ });
804
+ const updated = await findRepoByFullName(env.DB, fullName);
805
+ return jsonSuccess({ repo: updated });
806
+ }
807
+ async function handleDisallowSelfPassword(request, env, params) {
808
+ const auth = requireOperator(request, env);
809
+ if (!auth.authenticated) return auth.response;
810
+ const fullName = `${params["owner"]}/${params["repo"]}`.toLowerCase();
811
+ const repo = await findRepoByFullName(env.DB, fullName);
812
+ if (!repo) {
813
+ return jsonError("NOT_FOUND", `Repository '${fullName}' not found`, 404);
814
+ }
815
+ await setSelfPasswordAllowFlag(env.DB, repo.id, false);
816
+ await writeAuditEvent(env.DB, {
817
+ event_type: "repo.self_password_disallowed",
818
+ actor_type: "operator",
819
+ repo_id: repo.id
820
+ });
821
+ const updated = await findRepoByFullName(env.DB, fullName);
822
+ return jsonSuccess({ repo: updated });
823
+ }
824
+
825
+ // ../worker/src/handlers/rules.ts
826
+ async function handleListRules(request, env, _params) {
827
+ const auth = requireOperator(request, env);
828
+ if (!auth.authenticated) return auth.response;
829
+ const rules = await listRules(env.DB);
830
+ return jsonSuccess({ rules });
831
+ }
832
+ async function handleCreateRule(request, env, _params) {
833
+ const auth = requireOperator(request, env);
834
+ if (!auth.authenticated) return auth.response;
835
+ let body;
836
+ try {
837
+ body = await request.json();
838
+ } catch {
839
+ return jsonError("INVALID_BODY", "Request body must be valid JSON", 400);
840
+ }
841
+ if (!body.pattern || typeof body.pattern !== "string") {
842
+ return jsonError("VALIDATION_ERROR", "pattern is required", 400, { field: "pattern" });
843
+ }
844
+ const patternRegex = /^[a-zA-Z0-9_.-]+\/(\*|[a-zA-Z0-9_.-]+)$/;
845
+ if (!patternRegex.test(body.pattern)) {
846
+ return jsonError(
847
+ "VALIDATION_ERROR",
848
+ 'pattern must be in format "owner/repo" or "owner/*"',
849
+ 400,
850
+ { field: "pattern" }
851
+ );
852
+ }
853
+ if (!body.access_mode || !["public", "password"].includes(body.access_mode)) {
854
+ return jsonError(
855
+ "VALIDATION_ERROR",
856
+ 'access_mode must be "public" or "password"',
857
+ 400,
858
+ { field: "access_mode" }
859
+ );
860
+ }
861
+ const accessMode = body.access_mode;
862
+ const priority = typeof body.priority === "number" ? body.priority : 0;
863
+ let defaultAllowSelfPassword = true;
864
+ if (body.default_allow_repo_owner_password !== void 0) {
865
+ if (typeof body.default_allow_repo_owner_password !== "boolean") {
866
+ return jsonError(
867
+ "VALIDATION_ERROR",
868
+ "default_allow_repo_owner_password must be a boolean",
869
+ 400,
870
+ { field: "default_allow_repo_owner_password" }
871
+ );
872
+ }
873
+ defaultAllowSelfPassword = body.default_allow_repo_owner_password;
874
+ }
875
+ const rule = await createRule(env.DB, body.pattern, accessMode, "operator", priority, defaultAllowSelfPassword);
876
+ await writeAuditEvent(env.DB, {
877
+ event_type: "rule.created",
878
+ actor_type: "operator",
879
+ rule_id: rule.id,
880
+ metadata: { pattern: body.pattern, access_mode: accessMode, priority }
881
+ });
882
+ return jsonSuccess({ rule }, 201);
883
+ }
884
+ async function handleDeleteRule(request, env, params) {
885
+ const auth = requireOperator(request, env);
886
+ if (!auth.authenticated) return auth.response;
887
+ const ruleId = params["id"];
888
+ const deleted = await deleteRule(env.DB, ruleId);
889
+ if (!deleted) {
890
+ return jsonError("NOT_FOUND", `Rule '${ruleId}' not found`, 404);
891
+ }
892
+ await writeAuditEvent(env.DB, {
893
+ event_type: "rule.deleted",
894
+ actor_type: "operator",
895
+ rule_id: ruleId
896
+ });
897
+ return jsonSuccess({ deleted: true });
898
+ }
899
+
900
+ // ../worker/src/handlers/static-files.ts
901
+ async function handleListStatic(request, env, _params) {
902
+ const auth = requireOperator(request, env);
903
+ if (!auth.authenticated) return auth.response;
904
+ const files = ALLOWED_STATIC_KEYS.map((key) => ({
905
+ key,
906
+ source: "bundled_default",
907
+ custom: false
908
+ }));
909
+ return jsonSuccess({ files });
910
+ }
911
+ async function handleSetStatic(request, env, _params) {
912
+ const auth = requireOperator(request, env);
913
+ if (!auth.authenticated) return auth.response;
914
+ return jsonError("NOT_IMPLEMENTED", "Static file upload not yet implemented", 501);
915
+ }
916
+ async function handleDeleteStatic(request, env, _params) {
917
+ const auth = requireOperator(request, env);
918
+ if (!auth.authenticated) return auth.response;
919
+ return jsonError("NOT_IMPLEMENTED", "Static file deletion not yet implemented", 501);
920
+ }
921
+
922
+ // ../worker/src/oidc.ts
923
+ var GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com";
924
+ var GITHUB_JWKS_URL = "https://token.actions.githubusercontent.com/.well-known/jwks";
925
+ var cachedKeys = null;
926
+ var cacheTimestamp = 0;
927
+ var CACHE_TTL_MS = 60 * 60 * 1e3;
928
+ async function verifyGithubOidc(token, expectedAudience) {
929
+ const parts = token.split(".");
930
+ if (parts.length !== 3) {
931
+ return { ok: false, error: "Invalid JWT format: expected 3 parts" };
932
+ }
933
+ const [headerB64, payloadB64, signatureB64] = parts;
934
+ let header;
935
+ try {
936
+ header = JSON.parse(base64urlDecode(headerB64));
937
+ } catch (_e) {
938
+ return { ok: false, error: "Invalid JWT header" };
939
+ }
940
+ if (header.alg !== "RS256") {
941
+ return { ok: false, error: `Unsupported algorithm: ${header.alg}` };
942
+ }
943
+ if (!header.kid) {
944
+ return { ok: false, error: "JWT header missing kid" };
945
+ }
946
+ let payload;
947
+ try {
948
+ payload = JSON.parse(base64urlDecode(payloadB64));
949
+ } catch (_e) {
950
+ return { ok: false, error: "Invalid JWT payload" };
951
+ }
952
+ const claimError = validateStandardClaims(payload, expectedAudience);
953
+ if (claimError) {
954
+ return { ok: false, error: claimError };
955
+ }
956
+ const keys = await fetchJwks();
957
+ const key = keys.get(header.kid);
958
+ if (!key) {
959
+ cachedKeys = null;
960
+ const refreshedKeys = await fetchJwks();
961
+ const refreshedKey = refreshedKeys.get(header.kid);
962
+ if (!refreshedKey) {
963
+ return { ok: false, error: `Unknown key ID: ${header.kid}` };
964
+ }
965
+ const valid = await verifySignature(refreshedKey, headerB64, payloadB64, signatureB64);
966
+ if (!valid) {
967
+ return { ok: false, error: "Invalid JWT signature" };
968
+ }
969
+ } else {
970
+ const valid = await verifySignature(key, headerB64, payloadB64, signatureB64);
971
+ if (!valid) {
972
+ return { ok: false, error: "Invalid JWT signature" };
973
+ }
974
+ }
975
+ const claims = {
976
+ repository: payload.repository ?? "",
977
+ repository_id: payload.repository_id ?? "",
978
+ repository_owner: payload.repository_owner ?? "",
979
+ repository_owner_id: payload.repository_owner_id ?? "",
980
+ ref: payload.ref ?? "",
981
+ sha: payload.sha ?? "",
982
+ workflow_ref: payload.workflow_ref,
983
+ run_id: payload.run_id,
984
+ aud: typeof payload.aud === "string" ? payload.aud : payload.aud?.[0],
985
+ iss: payload.iss
986
+ };
987
+ if (!claims.repository) {
988
+ return { ok: false, error: "Token missing repository claim" };
989
+ }
990
+ return { ok: true, claims };
991
+ }
992
+ function validateStandardClaims(payload, expectedAudience) {
993
+ if (payload.iss !== GITHUB_OIDC_ISSUER) {
994
+ return `Invalid issuer: ${payload.iss}`;
995
+ }
996
+ const aud = typeof payload.aud === "string" ? [payload.aud] : payload.aud;
997
+ if (!aud || !aud.includes(expectedAudience)) {
998
+ return `Invalid audience: expected ${expectedAudience}`;
999
+ }
1000
+ const now = Math.floor(Date.now() / 1e3);
1001
+ if (payload.exp !== void 0 && payload.exp < now - 60) {
1002
+ return "Token has expired";
1003
+ }
1004
+ if (payload.nbf !== void 0 && payload.nbf > now + 60) {
1005
+ return "Token not yet valid";
1006
+ }
1007
+ return null;
1008
+ }
1009
+ function base64urlDecode(input) {
1010
+ const bytes = base64urlDecodeBytes(input);
1011
+ return new TextDecoder().decode(bytes);
1012
+ }
1013
+ function base64urlDecodeBytes(input) {
1014
+ let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
1015
+ const pad = base64.length % 4;
1016
+ if (pad === 2) base64 += "==";
1017
+ else if (pad === 3) base64 += "=";
1018
+ const binary = atob(base64);
1019
+ const bytes = new Uint8Array(binary.length);
1020
+ for (let i = 0; i < binary.length; i++) {
1021
+ bytes[i] = binary.charCodeAt(i);
1022
+ }
1023
+ return bytes;
1024
+ }
1025
+ async function verifySignature(key, headerB64, payloadB64, signatureB64) {
1026
+ const encoder = new TextEncoder();
1027
+ const data = encoder.encode(`${headerB64}.${payloadB64}`);
1028
+ const signature = base64urlDecodeBytes(signatureB64);
1029
+ return crypto.subtle.verify(
1030
+ { name: "RSASSA-PKCS1-v1_5" },
1031
+ key,
1032
+ signature,
1033
+ data
1034
+ );
1035
+ }
1036
+ async function fetchJwks() {
1037
+ const now = Date.now();
1038
+ if (cachedKeys && now - cacheTimestamp < CACHE_TTL_MS) {
1039
+ return cachedKeys;
1040
+ }
1041
+ const response = await fetch(GITHUB_JWKS_URL);
1042
+ if (!response.ok) {
1043
+ throw new Error(`Failed to fetch JWKS: ${response.status}`);
1044
+ }
1045
+ const jwks = await response.json();
1046
+ const keys = /* @__PURE__ */ new Map();
1047
+ for (const jwk of jwks.keys) {
1048
+ if (jwk.kty !== "RSA" || jwk.alg !== "RS256" || !jwk.kid) continue;
1049
+ const cryptoKey = await crypto.subtle.importKey(
1050
+ "jwk",
1051
+ {
1052
+ kty: jwk.kty,
1053
+ n: jwk.n,
1054
+ e: jwk.e,
1055
+ alg: "RS256"
1056
+ },
1057
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
1058
+ false,
1059
+ ["verify"]
1060
+ );
1061
+ keys.set(jwk.kid, cryptoKey);
1062
+ }
1063
+ cachedKeys = keys;
1064
+ cacheTimestamp = now;
1065
+ return keys;
1066
+ }
1067
+
1068
+ // ../worker/src/archive.ts
1069
+ var MANIFEST_FILENAME = "nrdocs-manifest.json";
1070
+ function validatePath(filePath) {
1071
+ if (!filePath || filePath.length === 0) {
1072
+ return { code: "INVALID_PATH", message: "Empty file path" };
1073
+ }
1074
+ if (filePath.includes("\0")) {
1075
+ return { code: "INVALID_PATH", message: `Path contains null bytes: ${filePath}` };
1076
+ }
1077
+ if (filePath.startsWith("/")) {
1078
+ return { code: "PATH_TRAVERSAL", message: `Absolute path not allowed: ${filePath}` };
1079
+ }
1080
+ if (filePath.includes("\\")) {
1081
+ return { code: "PATH_TRAVERSAL", message: `Backslash not allowed in path: ${filePath}` };
1082
+ }
1083
+ if (filePath.includes("..")) {
1084
+ return { code: "PATH_TRAVERSAL", message: `Path traversal not allowed: ${filePath}` };
1085
+ }
1086
+ return null;
1087
+ }
1088
+ function validateExtension(filePath) {
1089
+ const basename = filePath.split("/").pop() ?? filePath;
1090
+ if (basename === MANIFEST_FILENAME) {
1091
+ return null;
1092
+ }
1093
+ const lastDot = basename.lastIndexOf(".");
1094
+ if (lastDot === -1) {
1095
+ return { code: "INVALID_EXTENSION", message: `No extension found: ${filePath}` };
1096
+ }
1097
+ const ext = basename.slice(lastDot).toLowerCase();
1098
+ if (REJECTED_EXTENSIONS.has(ext)) {
1099
+ const normalized = filePath.replace(/\\/g, "/");
1100
+ if (normalized.startsWith("_nrdocs/")) {
1101
+ return null;
1102
+ }
1103
+ return { code: "REJECTED_EXTENSION", message: `Rejected extension ${ext}: ${filePath}` };
1104
+ }
1105
+ if (!ALLOWED_ASSET_EXTENSIONS.has(ext)) {
1106
+ return { code: "INVALID_EXTENSION", message: `Extension not allowed ${ext}: ${filePath}` };
1107
+ }
1108
+ return null;
1109
+ }
1110
+ async function extractArtifact(archive, limits) {
1111
+ const maxFileCount = limits?.maxFileCount ?? DEFAULT_MAX_FILE_COUNT;
1112
+ const maxTotalSize = limits?.maxTotalSize ?? DEFAULT_MAX_EXTRACTED_SIZE_MB * 1024 * 1024;
1113
+ const maxSingleFileSize = limits?.maxSingleFileSize ?? DEFAULT_MAX_SINGLE_FILE_SIZE_MB * 1024 * 1024;
1114
+ let tarData;
1115
+ try {
1116
+ tarData = await decompressGzip(archive);
1117
+ } catch (_e) {
1118
+ return { ok: false, error: { code: "DECOMPRESSION_FAILED", message: "Failed to decompress gzip archive" } };
1119
+ }
1120
+ const files = [];
1121
+ let totalSize = 0;
1122
+ let hasManifest = false;
1123
+ let manifest = {};
1124
+ let offset = 0;
1125
+ while (offset < tarData.length) {
1126
+ if (offset + 512 > tarData.length) break;
1127
+ if (isZeroBlock(tarData, offset)) break;
1128
+ const header = parseTarHeader(tarData, offset);
1129
+ if (!header) break;
1130
+ offset += 512;
1131
+ if (header.type !== "0" && header.type !== "" && header.type !== null) {
1132
+ if (header.type === "1" || header.type === "2") {
1133
+ return {
1134
+ ok: false,
1135
+ error: { code: "INVALID_ENTRY_TYPE", message: `Symlinks/hardlinks not allowed: ${header.name}` }
1136
+ };
1137
+ }
1138
+ if (header.type === "3" || header.type === "4" || header.type === "6") {
1139
+ return {
1140
+ ok: false,
1141
+ error: { code: "INVALID_ENTRY_TYPE", message: `Special file type not allowed: ${header.name}` }
1142
+ };
1143
+ }
1144
+ const blocks2 = Math.ceil(header.size / 512);
1145
+ offset += blocks2 * 512;
1146
+ continue;
1147
+ }
1148
+ if (header.size === 0) continue;
1149
+ const pathError = validatePath(header.name);
1150
+ if (pathError) return { ok: false, error: pathError };
1151
+ const extError = validateExtension(header.name);
1152
+ if (extError) return { ok: false, error: extError };
1153
+ if (header.size > maxSingleFileSize) {
1154
+ return {
1155
+ ok: false,
1156
+ error: {
1157
+ code: "FILE_TOO_LARGE",
1158
+ message: `File exceeds size limit (${header.size} bytes): ${header.name}`
1159
+ }
1160
+ };
1161
+ }
1162
+ totalSize += header.size;
1163
+ if (totalSize > maxTotalSize) {
1164
+ return {
1165
+ ok: false,
1166
+ error: { code: "TOTAL_SIZE_EXCEEDED", message: `Total extracted size exceeds limit` }
1167
+ };
1168
+ }
1169
+ if (files.length >= maxFileCount) {
1170
+ return {
1171
+ ok: false,
1172
+ error: { code: "FILE_COUNT_EXCEEDED", message: `File count exceeds limit of ${maxFileCount}` }
1173
+ };
1174
+ }
1175
+ const content = tarData.slice(offset, offset + header.size);
1176
+ files.push({ path: header.name, content, size: header.size });
1177
+ const basename = header.name.split("/").pop() ?? header.name;
1178
+ if (basename === MANIFEST_FILENAME) {
1179
+ hasManifest = true;
1180
+ try {
1181
+ const decoder = new TextDecoder();
1182
+ manifest = JSON.parse(decoder.decode(content));
1183
+ } catch (_e) {
1184
+ return { ok: false, error: { code: "INVALID_MANIFEST", message: "nrdocs-manifest.json is not valid JSON" } };
1185
+ }
1186
+ }
1187
+ const blocks = Math.ceil(header.size / 512);
1188
+ offset += blocks * 512;
1189
+ }
1190
+ if (!hasManifest) {
1191
+ return {
1192
+ ok: false,
1193
+ error: { code: "MISSING_MANIFEST", message: "Archive must contain nrdocs-manifest.json" }
1194
+ };
1195
+ }
1196
+ return {
1197
+ ok: true,
1198
+ result: {
1199
+ files,
1200
+ manifest,
1201
+ totalSize,
1202
+ fileCount: files.length
1203
+ }
1204
+ };
1205
+ }
1206
+ function parseTarHeader(data, offset) {
1207
+ const name = readString(data, offset, 100);
1208
+ if (!name) return null;
1209
+ const sizeStr = readString(data, offset + 124, 12);
1210
+ const size = parseOctal(sizeStr);
1211
+ const typeByte = data[offset + 156];
1212
+ const type = typeByte === 0 ? null : String.fromCharCode(typeByte);
1213
+ const prefix = readString(data, offset + 345, 155);
1214
+ const fullName = prefix ? `${prefix}/${name}` : name;
1215
+ const normalizedName = fullName.replace(/^\.\//, "");
1216
+ return { name: normalizedName, size, type };
1217
+ }
1218
+ function readString(data, offset, length) {
1219
+ let end = offset;
1220
+ const max = offset + length;
1221
+ while (end < max && data[end] !== 0) {
1222
+ end++;
1223
+ }
1224
+ const decoder = new TextDecoder();
1225
+ return decoder.decode(data.slice(offset, end)).trim();
1226
+ }
1227
+ function parseOctal(str) {
1228
+ const trimmed = str.trim();
1229
+ if (!trimmed) return 0;
1230
+ return parseInt(trimmed, 8) || 0;
1231
+ }
1232
+ function isZeroBlock(data, offset) {
1233
+ for (let i = 0; i < 512 && offset + i < data.length; i++) {
1234
+ if (data[offset + i] !== 0) return false;
1235
+ }
1236
+ return true;
1237
+ }
1238
+ async function decompressGzip(data) {
1239
+ const ds = new DecompressionStream("gzip");
1240
+ const writer = ds.writable.getWriter();
1241
+ const reader = ds.readable.getReader();
1242
+ writer.write(new Uint8Array(data));
1243
+ writer.close();
1244
+ const chunks = [];
1245
+ let totalLength = 0;
1246
+ while (true) {
1247
+ const { done, value } = await reader.read();
1248
+ if (done) break;
1249
+ chunks.push(value);
1250
+ totalLength += value.length;
1251
+ }
1252
+ const result = new Uint8Array(totalLength);
1253
+ let pos = 0;
1254
+ for (const chunk of chunks) {
1255
+ result.set(chunk, pos);
1256
+ pos += chunk.length;
1257
+ }
1258
+ return result;
1259
+ }
1260
+
1261
+ // ../worker/src/artifacts.ts
1262
+ function buildArtifactPrefix(repoId, buildId) {
1263
+ return `artifacts/${repoId}/${buildId}/`;
1264
+ }
1265
+ function buildArtifactKey(repoId, buildId, filePath) {
1266
+ return `${buildArtifactPrefix(repoId, buildId)}${filePath}`;
1267
+ }
1268
+ async function storeArtifactFile(r2, repoId, buildId, filePath, content, contentType) {
1269
+ const key = buildArtifactKey(repoId, buildId, filePath);
1270
+ const options = {};
1271
+ if (contentType) {
1272
+ options.httpMetadata = { contentType };
1273
+ }
1274
+ await r2.put(key, content, options);
1275
+ }
1276
+ async function getArtifactFile(r2, repoId, buildId, filePath) {
1277
+ const key = buildArtifactKey(repoId, buildId, filePath);
1278
+ return r2.get(key);
1279
+ }
1280
+
1281
+ // ../worker/src/mime.ts
1282
+ var MIME_MAP = {
1283
+ ".html": "text/html; charset=utf-8",
1284
+ ".js": "text/javascript; charset=utf-8",
1285
+ ".css": "text/css; charset=utf-8",
1286
+ ".json": "application/json; charset=utf-8",
1287
+ ".svg": "image/svg+xml",
1288
+ ".png": "image/png",
1289
+ ".jpg": "image/jpeg",
1290
+ ".jpeg": "image/jpeg",
1291
+ ".gif": "image/gif",
1292
+ ".webp": "image/webp",
1293
+ ".ico": "image/x-icon",
1294
+ ".txt": "text/plain; charset=utf-8",
1295
+ ".pdf": "application/pdf"
1296
+ };
1297
+ function getMimeType(filePath) {
1298
+ const ext = getExtension(filePath);
1299
+ if (!ext) return null;
1300
+ return MIME_MAP[ext] ?? null;
1301
+ }
1302
+ function getSecurityHeaders(filePath) {
1303
+ const headers = {
1304
+ "X-Content-Type-Options": "nosniff"
1305
+ };
1306
+ const ext = getExtension(filePath);
1307
+ if (ext === ".svg") {
1308
+ headers["Content-Security-Policy"] = "script-src 'none'; object-src 'none'; base-uri 'none'";
1309
+ }
1310
+ return headers;
1311
+ }
1312
+ function getExtension(filePath) {
1313
+ const lastDot = filePath.lastIndexOf(".");
1314
+ if (lastDot === -1) return null;
1315
+ const lastSlash = filePath.lastIndexOf("/");
1316
+ if (lastDot < lastSlash) return null;
1317
+ return filePath.slice(lastDot).toLowerCase();
1318
+ }
1319
+
1320
+ // ../worker/src/handlers/publish.ts
1321
+ async function handlePublish(request, env, _params) {
1322
+ const authHeader = request.headers.get("Authorization");
1323
+ if (!authHeader) {
1324
+ return jsonError("UNAUTHORIZED", "Missing Authorization header", 401);
1325
+ }
1326
+ const parts = authHeader.split(" ");
1327
+ if (parts.length !== 2 || parts[0] !== "Bearer") {
1328
+ return jsonError("UNAUTHORIZED", "Invalid Authorization header format", 401);
1329
+ }
1330
+ const token = parts[1];
1331
+ const oidcResult = await verifyGithubOidc(token, "nrdocs");
1332
+ if (!oidcResult.ok) {
1333
+ return jsonError("OIDC_VERIFICATION_FAILED", oidcResult.error, 401);
1334
+ }
1335
+ const claims = oidcResult.claims;
1336
+ const fullName = claims.repository.toLowerCase();
1337
+ const ownerName = claims.repository_owner.toLowerCase();
1338
+ const repoName = fullName.split("/")[1] ?? "";
1339
+ const existingRepo = await findRepoByGithubId(env.DB, claims.repository_id);
1340
+ if (existingRepo && existingRepo.approval_state === "disabled") {
1341
+ return jsonError("REPO_DISABLED", "This repository has been disabled by the operator", 409);
1342
+ }
1343
+ let matchedRuleForNewRepo = null;
1344
+ if (!existingRepo) {
1345
+ matchedRuleForNewRepo = await matchRules(env.DB, fullName);
1346
+ if (!matchedRuleForNewRepo) {
1347
+ return jsonError(
1348
+ "REPO_NOT_ALLOWED",
1349
+ `Repository '${fullName}' is not allowed to publish to this instance. The operator must add an auto-approval rule first. Run: nrdocs rules add '${ownerName}/*' --access password`,
1350
+ 403
1351
+ );
1352
+ }
1353
+ }
1354
+ let formData;
1355
+ try {
1356
+ formData = await request.formData();
1357
+ } catch (_e) {
1358
+ return jsonError("INVALID_REQUEST", "Expected multipart/form-data body", 400);
1359
+ }
1360
+ const metadataField = formData.get("metadata");
1361
+ const artifactField = formData.get("artifact");
1362
+ if (!artifactField || typeof artifactField === "string") {
1363
+ return jsonError("INVALID_REQUEST", "Missing artifact file in form data", 400);
1364
+ }
1365
+ const artifactFile = artifactField;
1366
+ let metadata = {};
1367
+ if (metadataField) {
1368
+ try {
1369
+ metadata = JSON.parse(metadataField);
1370
+ } catch (_e) {
1371
+ return jsonError("INVALID_METADATA", "metadata field must be valid JSON", 400);
1372
+ }
1373
+ if (metadata.site_title !== void 0 && typeof metadata.site_title !== "string") {
1374
+ return jsonError("INVALID_METADATA", "site_title must be a string", 400);
1375
+ }
1376
+ if (metadata.requested_access !== void 0) {
1377
+ if (!["public", "password"].includes(metadata.requested_access)) {
1378
+ return jsonError("INVALID_METADATA", 'requested_access must be "public" or "password"', 400);
1379
+ }
1380
+ }
1381
+ }
1382
+ const maxArchiveSize = DEFAULT_MAX_ARCHIVE_SIZE_MB * 1024 * 1024;
1383
+ const archiveBuffer = await artifactFile.arrayBuffer();
1384
+ if (archiveBuffer.byteLength > maxArchiveSize) {
1385
+ return jsonError(
1386
+ "ARCHIVE_TOO_LARGE",
1387
+ `Archive exceeds ${DEFAULT_MAX_ARCHIVE_SIZE_MB}MB limit`,
1388
+ 400
1389
+ );
1390
+ }
1391
+ const extractResult = await extractArtifact(archiveBuffer);
1392
+ if (!extractResult.ok) {
1393
+ return jsonError(
1394
+ "EXTRACTION_FAILED",
1395
+ extractResult.error.message,
1396
+ 400,
1397
+ { code: extractResult.error.code }
1398
+ );
1399
+ }
1400
+ const { files, totalSize, fileCount } = extractResult.result;
1401
+ const repo = await upsertRepo(env.DB, {
1402
+ github_repository_id: claims.repository_id,
1403
+ owner: ownerName,
1404
+ name: repoName,
1405
+ full_name: fullName,
1406
+ site_title: metadata.site_title,
1407
+ requested_access: metadata.requested_access,
1408
+ allow_repo_owner_password: matchedRuleForNewRepo?.default_allow_repo_owner_password
1409
+ });
1410
+ const build = await createBuild(env.DB, {
1411
+ repo_id: repo.id,
1412
+ github_repository_id: claims.repository_id,
1413
+ git_sha: claims.sha,
1414
+ git_ref: claims.ref,
1415
+ workflow_ref: claims.workflow_ref,
1416
+ run_id: claims.run_id
1417
+ });
1418
+ try {
1419
+ for (const file of files) {
1420
+ const artifactPath = file.path.toLowerCase();
1421
+ const contentType = getMimeType(artifactPath) ?? "application/octet-stream";
1422
+ await storeArtifactFile(
1423
+ env.ARTIFACTS,
1424
+ repo.id,
1425
+ build.id,
1426
+ artifactPath,
1427
+ file.content.buffer,
1428
+ contentType
1429
+ );
1430
+ }
1431
+ } catch (_e) {
1432
+ await markBuildFailed(env.DB, build.id, "STORAGE_FAILED", "Failed to store artifact files in R2");
1433
+ return jsonError("STORAGE_FAILED", "Failed to store artifact files", 500);
1434
+ }
1435
+ const contentHash = await computeContentHash(files);
1436
+ const prefix = buildArtifactPrefix(repo.id, build.id);
1437
+ const successBuild = await markBuildSuccess(env.DB, build.id, prefix, totalSize, fileCount, contentHash);
1438
+ await updateLatestBuild(env.DB, repo.id, build.id);
1439
+ await writeAuditEvent(env.DB, {
1440
+ event_type: "build.published",
1441
+ actor_type: "github_action",
1442
+ actor_id: fullName,
1443
+ repo_id: repo.id,
1444
+ build_id: build.id,
1445
+ metadata: {
1446
+ git_sha: claims.sha,
1447
+ git_ref: claims.ref,
1448
+ file_count: fileCount,
1449
+ total_size: totalSize
1450
+ }
1451
+ });
1452
+ const passwordRaw = formData.get("password");
1453
+ if (typeof passwordRaw === "string") {
1454
+ const currentRepo = await findRepoByGithubId(env.DB, claims.repository_id);
1455
+ if (currentRepo && currentRepo.allow_repo_owner_password === true) {
1456
+ if (passwordRaw.length < DEFAULT_MIN_PASSWORD_LENGTH || passwordRaw.length > DEFAULT_MAX_PASSWORD_LENGTH) {
1457
+ return jsonError(
1458
+ "INVALID_PASSWORD",
1459
+ `Password must be between ${DEFAULT_MIN_PASSWORD_LENGTH} and ${DEFAULT_MAX_PASSWORD_LENGTH} characters`,
1460
+ 400
1461
+ );
1462
+ }
1463
+ const result = await storeSelfServicePassword(env.DB, {
1464
+ repo: currentRepo,
1465
+ plaintext: passwordRaw,
1466
+ fullName,
1467
+ buildId: build.id
1468
+ });
1469
+ if (!result.ok) {
1470
+ return jsonError(
1471
+ "AUDIT_WRITE_FAILED",
1472
+ "Failed to record self-service password change",
1473
+ 500
1474
+ );
1475
+ }
1476
+ } else if (currentRepo && currentRepo.allow_repo_owner_password === false) {
1477
+ try {
1478
+ await writeAuditEvent(env.DB, {
1479
+ event_type: "repo.self_password_ignored",
1480
+ actor_type: "github_action",
1481
+ actor_id: fullName,
1482
+ repo_id: currentRepo.id,
1483
+ build_id: build.id
1484
+ });
1485
+ } catch (_e) {
1486
+ return jsonError(
1487
+ "AUDIT_WRITE_FAILED",
1488
+ "Failed to record self-service password decision",
1489
+ 500
1490
+ );
1491
+ }
1492
+ }
1493
+ }
1494
+ let approvalState = repo.approval_state;
1495
+ let approvalSource = null;
1496
+ let accessMode = repo.access_mode;
1497
+ const updatedRepo = await findRepoByGithubId(env.DB, claims.repository_id);
1498
+ if (updatedRepo) {
1499
+ approvalState = updatedRepo.approval_state;
1500
+ accessMode = updatedRepo.access_mode;
1501
+ }
1502
+ if (approvalState === "pending") {
1503
+ const matchedRule = await matchRules(env.DB, fullName);
1504
+ if (matchedRule) {
1505
+ const approved = await approveRepo(env.DB, repo.id, matchedRule.access_mode, `auto_rule:${matchedRule.id}`);
1506
+ approvalState = approved.approval_state;
1507
+ accessMode = approved.access_mode;
1508
+ approvalSource = `auto_rule:${matchedRule.id}`;
1509
+ await writeAuditEvent(env.DB, {
1510
+ event_type: "repo.auto_approved",
1511
+ actor_type: "system",
1512
+ actor_id: "auto_approval",
1513
+ repo_id: repo.id,
1514
+ rule_id: matchedRule.id,
1515
+ metadata: {
1516
+ pattern: matchedRule.pattern,
1517
+ access_mode: matchedRule.access_mode
1518
+ }
1519
+ });
1520
+ }
1521
+ }
1522
+ const servingUrl = `${env.BASE_URL}/${fullName}/`;
1523
+ let visible = false;
1524
+ let reason;
1525
+ let requiresPassword = false;
1526
+ if (approvalState !== "approved") {
1527
+ reason = "awaiting_operator_approval";
1528
+ } else if (accessMode === "none") {
1529
+ reason = "access_mode_not_set";
1530
+ } else if (accessMode === "password") {
1531
+ const hasPw = await hasPassword(env.DB, repo.id);
1532
+ if (!hasPw) {
1533
+ reason = "needs_password";
1534
+ requiresPassword = true;
1535
+ } else {
1536
+ visible = true;
1537
+ reason = "serving";
1538
+ requiresPassword = true;
1539
+ }
1540
+ } else {
1541
+ visible = true;
1542
+ reason = "serving";
1543
+ }
1544
+ const responseData = {
1545
+ repo: {
1546
+ full_name: fullName,
1547
+ github_repository_id: claims.repository_id
1548
+ },
1549
+ build: {
1550
+ id: successBuild.id,
1551
+ status: successBuild.status,
1552
+ git_sha: claims.sha
1553
+ },
1554
+ approval: {
1555
+ state: approvalState,
1556
+ source: approvalSource
1557
+ },
1558
+ access: {
1559
+ mode: accessMode
1560
+ },
1561
+ serving: {
1562
+ visible,
1563
+ reason,
1564
+ url: servingUrl,
1565
+ ...requiresPassword ? { requires_password: true } : {}
1566
+ }
1567
+ };
1568
+ return jsonSuccess(responseData);
1569
+ }
1570
+ async function computeContentHash(files) {
1571
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
1572
+ const encoder = new TextEncoder();
1573
+ const parts = [];
1574
+ for (const file of sorted) {
1575
+ parts.push(encoder.encode(file.path + "\n"));
1576
+ parts.push(file.content);
1577
+ }
1578
+ let totalLen = 0;
1579
+ for (const p of parts) totalLen += p.length;
1580
+ const combined = new Uint8Array(totalLen);
1581
+ let offset = 0;
1582
+ for (const p of parts) {
1583
+ combined.set(p, offset);
1584
+ offset += p.length;
1585
+ }
1586
+ const hashBuffer = await crypto.subtle.digest("SHA-256", combined);
1587
+ const hashArray = new Uint8Array(hashBuffer);
1588
+ let hex = "";
1589
+ for (let i = 0; i < hashArray.length; i++) {
1590
+ hex += hashArray[i].toString(16).padStart(2, "0");
1591
+ }
1592
+ return hex;
1593
+ }
1594
+
1595
+ // ../worker/src/handlers/audit.ts
1596
+ async function handleAuditLog(request, env, _params) {
1597
+ const auth = requireOperator(request, env);
1598
+ if (!auth.authenticated) {
1599
+ return auth.response;
1600
+ }
1601
+ const url = new URL(request.url);
1602
+ const repoFilter = url.searchParams.get("repo") ?? void 0;
1603
+ const eventFilter = url.searchParams.get("event") ?? void 0;
1604
+ const cursor = url.searchParams.get("cursor") ?? void 0;
1605
+ const limitParam = url.searchParams.get("limit");
1606
+ let limit = 50;
1607
+ if (limitParam) {
1608
+ const parsed = parseInt(limitParam, 10);
1609
+ if (!isNaN(parsed) && parsed > 0) {
1610
+ limit = Math.min(parsed, 100);
1611
+ }
1612
+ }
1613
+ const conditions = [];
1614
+ const bindings = [];
1615
+ if (repoFilter) {
1616
+ conditions.push(
1617
+ `repo_id IN (SELECT id FROM repos WHERE full_name = ?)`
1618
+ );
1619
+ bindings.push(repoFilter.toLowerCase());
1620
+ }
1621
+ if (eventFilter) {
1622
+ conditions.push("event_type = ?");
1623
+ bindings.push(eventFilter);
1624
+ }
1625
+ if (cursor) {
1626
+ conditions.push("created_at < ?");
1627
+ bindings.push(cursor);
1628
+ }
1629
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1630
+ const query = `SELECT id, event_type, actor_type, actor_id, repo_id, build_id, rule_id, metadata_json, created_at
1631
+ FROM audit_log ${whereClause}
1632
+ ORDER BY created_at DESC
1633
+ LIMIT ?`;
1634
+ bindings.push(limit + 1);
1635
+ const stmt = env.DB.prepare(query);
1636
+ const result = await stmt.bind(...bindings).all();
1637
+ const rows = result.results ?? [];
1638
+ const hasMore = rows.length > limit;
1639
+ const events = hasMore ? rows.slice(0, limit) : rows;
1640
+ const items = events.map((row) => ({
1641
+ id: row.id,
1642
+ event_type: row.event_type,
1643
+ actor_type: row.actor_type,
1644
+ actor_id: row.actor_id,
1645
+ repo_id: row.repo_id,
1646
+ build_id: row.build_id,
1647
+ rule_id: row.rule_id,
1648
+ metadata: row.metadata_json ? JSON.parse(row.metadata_json) : null,
1649
+ created_at: row.created_at
1650
+ }));
1651
+ const nextCursor = hasMore && events.length > 0 ? events[events.length - 1].created_at : null;
1652
+ return jsonSuccess({
1653
+ items,
1654
+ cursor: nextCursor,
1655
+ has_more: hasMore
1656
+ });
1657
+ }
1658
+
1659
+ // ../worker/src/path-resolver.ts
1660
+ function resolveServingPath(requestPath, owner, repo) {
1661
+ const prefix = `/${owner}/${repo}`;
1662
+ if (requestPath === prefix) {
1663
+ return { type: "redirect", location: `${prefix}/` };
1664
+ }
1665
+ if (!requestPath.startsWith(`${prefix}/`)) {
1666
+ return { type: "not_found" };
1667
+ }
1668
+ const relativePath = requestPath.slice(prefix.length + 1);
1669
+ if (relativePath === "") {
1670
+ return { type: "serve", filePath: "index.html" };
1671
+ }
1672
+ const normalizedRelative = relativePath.replace(/\/+$/, "");
1673
+ const ext = getExtension2(normalizedRelative);
1674
+ if (isPlatformRuntimePath(normalizedRelative, ext)) {
1675
+ return { type: "serve", filePath: normalizedRelative };
1676
+ }
1677
+ if (ext && ext !== ".html" && ALLOWED_ASSET_EXTENSIONS.has(ext)) {
1678
+ return { type: "serve", filePath: relativePath };
1679
+ }
1680
+ if (ext === ".html") {
1681
+ const withoutExt = relativePath.slice(0, -5);
1682
+ if (withoutExt.endsWith("/index") || withoutExt === "index") {
1683
+ const dir = withoutExt === "index" ? "" : withoutExt.slice(0, -6);
1684
+ return { type: "redirect", location: `${prefix}/${dir}${dir ? "/" : ""}` };
1685
+ }
1686
+ return { type: "redirect", location: `${prefix}/${withoutExt}/` };
1687
+ }
1688
+ if (relativePath.endsWith("/")) {
1689
+ return { type: "serve", filePath: `${relativePath}index.html` };
1690
+ }
1691
+ return { type: "redirect", location: `${prefix}/${relativePath}/` };
1692
+ }
1693
+ function isReservedPath(pathname) {
1694
+ if (pathname === "/favicon.ico" || pathname === "/robots.txt") {
1695
+ return true;
1696
+ }
1697
+ if (pathname.startsWith("/api/") || pathname.startsWith("/api") && pathname.length === 4 || pathname.startsWith("/_nrdocs/") || pathname.startsWith("/_nrdocs") && pathname.length === 8 || pathname.startsWith("/.well-known/")) {
1698
+ return true;
1699
+ }
1700
+ return false;
1701
+ }
1702
+ function isPlatformRuntimePath(normalizedRelative, ext) {
1703
+ if (!normalizedRelative.startsWith("_nrdocs/")) return false;
1704
+ return ext === ".js" || ext === ".mjs" || ext === ".cjs";
1705
+ }
1706
+ function getExtension2(filePath) {
1707
+ const lastDot = filePath.lastIndexOf(".");
1708
+ if (lastDot === -1) return null;
1709
+ const lastSlash = filePath.lastIndexOf("/");
1710
+ if (lastDot < lastSlash) return null;
1711
+ return filePath.slice(lastDot).toLowerCase();
1712
+ }
1713
+
1714
+ // ../worker/src/session.ts
1715
+ function normalizeRepoPath(repoPath) {
1716
+ const trimmed = repoPath.replace(/^\//, "").replace(/\/$/, "");
1717
+ if (!trimmed) return "/";
1718
+ const segments = trimmed.split("/").filter(Boolean).map((s) => s.toLowerCase());
1719
+ if (segments.length < 2) return `/${segments.join("/")}`;
1720
+ return `/${segments[0]}/${segments[1]}`;
1721
+ }
1722
+ function normalizeRepoFullName(fullName) {
1723
+ const parts = fullName.split("/").filter(Boolean);
1724
+ if (parts.length < 2) return fullName.toLowerCase();
1725
+ return `${parts[0].toLowerCase()}/${parts[1].toLowerCase()}`;
1726
+ }
1727
+ async function createSessionCookie(data, secret) {
1728
+ const payload = JSON.stringify(data);
1729
+ const payloadB64 = base64urlEncode(payload);
1730
+ const signature = await hmacSign(payload, secret);
1731
+ const signatureB64 = base64urlEncode(signature);
1732
+ return `${payloadB64}.${signatureB64}`;
1733
+ }
1734
+ async function validateSessionCookie(cookie, secret) {
1735
+ const dotIndex = cookie.indexOf(".");
1736
+ if (dotIndex === -1) return null;
1737
+ const payloadB64 = cookie.slice(0, dotIndex);
1738
+ const signatureB64 = cookie.slice(dotIndex + 1);
1739
+ if (!payloadB64 || !signatureB64) return null;
1740
+ let payload;
1741
+ try {
1742
+ payload = base64urlDecode2(payloadB64);
1743
+ } catch {
1744
+ return null;
1745
+ }
1746
+ const expectedSignature = await hmacSign(payload, secret);
1747
+ const expectedB64 = base64urlEncode(expectedSignature);
1748
+ if (!constantTimeEqual2(signatureB64, expectedB64)) {
1749
+ return null;
1750
+ }
1751
+ let data;
1752
+ try {
1753
+ data = JSON.parse(payload);
1754
+ } catch {
1755
+ return null;
1756
+ }
1757
+ if (!data.repo_id || typeof data.password_version !== "number" || typeof data.expires_at !== "number") {
1758
+ return null;
1759
+ }
1760
+ if (Date.now() > data.expires_at) {
1761
+ return null;
1762
+ }
1763
+ return data;
1764
+ }
1765
+ async function findSessionForRepo(request, repoPath, repoId, secret) {
1766
+ const cookieHeader = request.headers.get("Cookie");
1767
+ if (!cookieHeader) return null;
1768
+ const cookies = parseCookies(cookieHeader);
1769
+ const seen = /* @__PURE__ */ new Set();
1770
+ const preferred = cookies[getSessionCookieName(repoPath)];
1771
+ if (preferred) seen.add(preferred);
1772
+ for (const [name, value] of Object.entries(cookies)) {
1773
+ if (name.startsWith("__nrdocs_session_")) {
1774
+ seen.add(value);
1775
+ }
1776
+ }
1777
+ for (const value of seen) {
1778
+ const session = await validateSessionCookie(value, secret);
1779
+ if (session?.repo_id === repoId) return session;
1780
+ }
1781
+ return null;
1782
+ }
1783
+ function buildSetCookieHeader(cookieValue, repoPath, maxAgeSeconds) {
1784
+ const canonical = normalizeRepoPath(repoPath);
1785
+ const cookieName = getSessionCookieName(canonical);
1786
+ return `${cookieName}=${cookieValue}; HttpOnly; Secure; SameSite=Lax; Path=${canonical}/; Max-Age=${maxAgeSeconds}`;
1787
+ }
1788
+ function getSessionCookieName(repoPath) {
1789
+ const normalized = normalizeRepoPath(repoPath).replace(/^\//, "").replace(/\/$/, "").replace(/\//g, "_");
1790
+ return `__nrdocs_session_${normalized}`;
1791
+ }
1792
+ function parseCookies(header) {
1793
+ const cookies = {};
1794
+ const pairs = header.split(";");
1795
+ for (const pair of pairs) {
1796
+ const eqIndex = pair.indexOf("=");
1797
+ if (eqIndex === -1) continue;
1798
+ const key = pair.slice(0, eqIndex).trim();
1799
+ const value = pair.slice(eqIndex + 1).trim();
1800
+ if (key) {
1801
+ cookies[key] = value;
1802
+ }
1803
+ }
1804
+ return cookies;
1805
+ }
1806
+ async function hmacSign(data, secret) {
1807
+ const encoder = new TextEncoder();
1808
+ const key = await crypto.subtle.importKey(
1809
+ "raw",
1810
+ encoder.encode(secret),
1811
+ { name: "HMAC", hash: "SHA-256" },
1812
+ false,
1813
+ ["sign"]
1814
+ );
1815
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
1816
+ const bytes = new Uint8Array(signature);
1817
+ let hex = "";
1818
+ for (let i = 0; i < bytes.length; i++) {
1819
+ hex += bytes[i].toString(16).padStart(2, "0");
1820
+ }
1821
+ return hex;
1822
+ }
1823
+ function base64urlEncode(input) {
1824
+ const encoder = new TextEncoder();
1825
+ const bytes = encoder.encode(input);
1826
+ let binary = "";
1827
+ for (let i = 0; i < bytes.length; i++) {
1828
+ binary += String.fromCharCode(bytes[i]);
1829
+ }
1830
+ const base64 = btoa(binary);
1831
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1832
+ }
1833
+ function base64urlDecode2(input) {
1834
+ let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
1835
+ while (base64.length % 4 !== 0) {
1836
+ base64 += "=";
1837
+ }
1838
+ const binary = atob(base64);
1839
+ const bytes = new Uint8Array(binary.length);
1840
+ for (let i = 0; i < binary.length; i++) {
1841
+ bytes[i] = binary.charCodeAt(i);
1842
+ }
1843
+ const decoder = new TextDecoder();
1844
+ return decoder.decode(bytes);
1845
+ }
1846
+ function constantTimeEqual2(a, b) {
1847
+ if (a.length !== b.length) return false;
1848
+ let result = 0;
1849
+ for (let i = 0; i < a.length; i++) {
1850
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
1851
+ }
1852
+ return result === 0;
1853
+ }
1854
+
1855
+ // ../worker/src/handlers/password-page.ts
1856
+ function renderPasswordPage(repoFullName, error) {
1857
+ const errorHtml = error ? `<p class="error" role="alert">${escapeHtml(error)}</p>` : "";
1858
+ const html = `<!DOCTYPE html>
1859
+ <html lang="en">
1860
+ <head>
1861
+ <meta charset="utf-8">
1862
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1863
+ <title>Password Required</title>
1864
+ <style>
1865
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1866
+ body {
1867
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
1868
+ display: flex;
1869
+ align-items: center;
1870
+ justify-content: center;
1871
+ min-height: 100vh;
1872
+ background: #f5f5f5;
1873
+ color: #333;
1874
+ }
1875
+ .container {
1876
+ background: #fff;
1877
+ border-radius: 8px;
1878
+ padding: 2rem;
1879
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
1880
+ width: 100%;
1881
+ max-width: 400px;
1882
+ }
1883
+ h1 { font-size: 1.25rem; margin-bottom: 1rem; }
1884
+ label { display: block; font-size: 0.875rem; margin-bottom: 0.5rem; font-weight: 500; }
1885
+ input[type="password"] {
1886
+ width: 100%;
1887
+ padding: 0.5rem 0.75rem;
1888
+ border: 1px solid #ccc;
1889
+ border-radius: 4px;
1890
+ font-size: 1rem;
1891
+ margin-bottom: 1rem;
1892
+ }
1893
+ input[type="password"]:focus {
1894
+ outline: 2px solid #0066cc;
1895
+ outline-offset: 1px;
1896
+ border-color: #0066cc;
1897
+ }
1898
+ button {
1899
+ width: 100%;
1900
+ padding: 0.625rem;
1901
+ background: #0066cc;
1902
+ color: #fff;
1903
+ border: none;
1904
+ border-radius: 4px;
1905
+ font-size: 1rem;
1906
+ cursor: pointer;
1907
+ }
1908
+ button:hover { background: #0052a3; }
1909
+ button:focus { outline: 2px solid #0066cc; outline-offset: 2px; }
1910
+ .error { color: #cc0000; font-size: 0.875rem; margin-bottom: 1rem; }
1911
+ </style>
1912
+ </head>
1913
+ <body>
1914
+ <main class="container">
1915
+ <h1>Password Required</h1>
1916
+ ${errorHtml}
1917
+ <form method="POST" action="/_nrdocs/login">
1918
+ <input type="hidden" name="repo" value="${escapeHtml(repoFullName)}">
1919
+ <label for="password">Enter password to view documentation</label>
1920
+ <input type="password" id="password" name="password" required autocomplete="current-password">
1921
+ <button type="submit">Submit</button>
1922
+ </form>
1923
+ </main>
1924
+ </body>
1925
+ </html>`;
1926
+ return new Response(html, {
1927
+ status: 200,
1928
+ headers: {
1929
+ "Content-Type": "text/html; charset=utf-8",
1930
+ "Cache-Control": "no-store",
1931
+ "X-Content-Type-Options": "nosniff"
1932
+ }
1933
+ });
1934
+ }
1935
+ function escapeHtml(str) {
1936
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
1937
+ }
1938
+
1939
+ // ../worker/src/handlers/serve.ts
1940
+ async function handleServe(request, env, url) {
1941
+ const segments = url.pathname.split("/").filter(Boolean);
1942
+ if (segments.length < 2) {
1943
+ return notFound();
1944
+ }
1945
+ const rawOwner = segments[0];
1946
+ const rawRepo = segments[1];
1947
+ const owner = rawOwner.toLowerCase();
1948
+ const repo = rawRepo.toLowerCase();
1949
+ if (rawOwner !== owner || rawRepo !== repo) {
1950
+ const normalized = url.pathname.replace(`/${rawOwner}/${rawRepo}`, `/${owner}/${repo}`) + url.search;
1951
+ return new Response(null, {
1952
+ status: 301,
1953
+ headers: { Location: normalized }
1954
+ });
1955
+ }
1956
+ const fullName = `${owner}/${repo}`;
1957
+ const repoRecord = await findRepoByFullName(env.DB, fullName);
1958
+ if (!repoRecord) return notFound();
1959
+ if (repoRecord.approval_state !== "approved") return notFound();
1960
+ if (!repoRecord.latest_successful_build_id) return notFound();
1961
+ if (repoRecord.access_mode === "none") return notFound();
1962
+ if (repoRecord.access_mode === "password") {
1963
+ const repoPath = normalizeRepoPath(`/${owner}/${repo}`);
1964
+ let authenticated = false;
1965
+ const session = await findSessionForRepo(
1966
+ request,
1967
+ repoPath,
1968
+ repoRecord.id,
1969
+ env.SESSION_SECRET
1970
+ );
1971
+ if (session) {
1972
+ const credential = await getActivePassword(env.DB, repoRecord.id);
1973
+ if (credential && session.password_version === credential.password_version) {
1974
+ authenticated = true;
1975
+ }
1976
+ }
1977
+ if (!authenticated) {
1978
+ return renderPasswordPage(fullName);
1979
+ }
1980
+ }
1981
+ const resolution = resolveServingPath(url.pathname, owner, repo);
1982
+ switch (resolution.type) {
1983
+ case "redirect":
1984
+ return new Response(null, {
1985
+ status: 301,
1986
+ headers: { Location: resolution.location }
1987
+ });
1988
+ case "not_found":
1989
+ return notFound();
1990
+ case "serve": {
1991
+ const build = await findBuildById(env.DB, repoRecord.latest_successful_build_id);
1992
+ if (!build || !build.artifact_prefix) {
1993
+ return notFound();
1994
+ }
1995
+ let filePath = resolution.filePath;
1996
+ let file = await getArtifactFile(
1997
+ env.ARTIFACTS,
1998
+ repoRecord.id,
1999
+ build.id,
2000
+ filePath
2001
+ );
2002
+ if (!file && filePath !== filePath.toLowerCase()) {
2003
+ filePath = filePath.toLowerCase();
2004
+ file = await getArtifactFile(
2005
+ env.ARTIFACTS,
2006
+ repoRecord.id,
2007
+ build.id,
2008
+ filePath
2009
+ );
2010
+ }
2011
+ if (!file && filePath === "index.html") {
2012
+ const fallback = await resolveRootPageFromManifest(
2013
+ env,
2014
+ repoRecord.id,
2015
+ build.id
2016
+ );
2017
+ if (fallback) {
2018
+ return new Response(null, {
2019
+ status: 302,
2020
+ headers: { Location: `/${owner}/${repo}/${fallback}` }
2021
+ });
2022
+ }
2023
+ }
2024
+ if (!file) {
2025
+ return notFound();
2026
+ }
2027
+ const mimeType = getMimeType(filePath) ?? "application/octet-stream";
2028
+ const securityHeaders = getSecurityHeaders(filePath);
2029
+ return new Response(file.body, {
2030
+ status: 200,
2031
+ headers: {
2032
+ "Content-Type": mimeType,
2033
+ "Cache-Control": "public, max-age=300",
2034
+ ...securityHeaders
2035
+ }
2036
+ });
2037
+ }
2038
+ }
2039
+ }
2040
+ async function resolveRootPageFromManifest(env, repoId, buildId) {
2041
+ const manifestFile = await getArtifactFile(env.ARTIFACTS, repoId, buildId, "nrdocs-manifest.json");
2042
+ if (!manifestFile) return null;
2043
+ let manifest;
2044
+ try {
2045
+ manifest = JSON.parse(await manifestFile.text());
2046
+ } catch {
2047
+ return null;
2048
+ }
2049
+ const pages = manifest.pages ?? [];
2050
+ if (pages.length === 0) return null;
2051
+ const indexPage = pages.find((p) => p.path === "index.html");
2052
+ const pagePath = indexPage?.path ?? pages[0]?.path;
2053
+ if (!pagePath || pagePath === "index.html") return null;
2054
+ const dir = pagePath.replace(/\/index\.html$/, "");
2055
+ return dir ? `${dir}/` : null;
2056
+ }
2057
+ function notFound() {
2058
+ return new Response("Not Found", {
2059
+ status: 404,
2060
+ headers: {
2061
+ "Content-Type": "text/plain; charset=utf-8",
2062
+ "X-Content-Type-Options": "nosniff"
2063
+ }
2064
+ });
2065
+ }
2066
+
2067
+ // ../worker/src/handlers/password-auth.ts
2068
+ var SESSION_MAX_AGE_SECONDS = 86400;
2069
+ async function handlePasswordLogin(request, env) {
2070
+ const contentType = request.headers.get("Content-Type") ?? "";
2071
+ if (!contentType.includes("application/x-www-form-urlencoded")) {
2072
+ return jsonError("BAD_REQUEST", "Invalid content type", 400);
2073
+ }
2074
+ let formData;
2075
+ try {
2076
+ const body = await request.text();
2077
+ formData = new URLSearchParams(body);
2078
+ } catch {
2079
+ return jsonError("BAD_REQUEST", "Invalid form data", 400);
2080
+ }
2081
+ const password = formData.get("password") ?? "";
2082
+ const repoFullName = normalizeRepoFullName(formData.get("repo") ?? "");
2083
+ if (!password || !repoFullName) {
2084
+ return jsonError("BAD_REQUEST", "Missing required fields", 400);
2085
+ }
2086
+ const repo = await findRepoByFullName(env.DB, repoFullName);
2087
+ if (!repo || repo.approval_state !== "approved" || repo.access_mode !== "password") {
2088
+ return jsonError("NOT_FOUND", "Not found", 404);
2089
+ }
2090
+ const credential = await getActivePassword(env.DB, repo.id);
2091
+ if (!credential) {
2092
+ return jsonError("NOT_FOUND", "Not found", 404);
2093
+ }
2094
+ const valid = await verifyPassword(
2095
+ password,
2096
+ credential.password_hash,
2097
+ credential.salt,
2098
+ credential.iteration_count
2099
+ );
2100
+ if (!valid) {
2101
+ return renderPasswordPage(repoFullName, "Incorrect password. Please try again.");
2102
+ }
2103
+ const sessionData = {
2104
+ repo_id: repo.id,
2105
+ password_version: credential.password_version,
2106
+ expires_at: Date.now() + SESSION_MAX_AGE_SECONDS * 1e3
2107
+ };
2108
+ const cookieValue = await createSessionCookie(sessionData, env.SESSION_SECRET);
2109
+ const repoPath = normalizeRepoPath(`/${repo.full_name}`);
2110
+ const setCookie = buildSetCookieHeader(cookieValue, repoPath, SESSION_MAX_AGE_SECONDS);
2111
+ return new Response(null, {
2112
+ status: 303,
2113
+ headers: {
2114
+ Location: `${repoPath}/`,
2115
+ "Set-Cookie": setCookie
2116
+ }
2117
+ });
2118
+ }
2119
+
2120
+ // ../worker/src/handlers/homepage.ts
2121
+ function handleHomepage(_request, env) {
2122
+ const html = `<!doctype html>
2123
+ <html lang="en">
2124
+ <head>
2125
+ <meta charset="utf-8">
2126
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2127
+ <meta name="description" content="nrdocs publishes documentation from GitHub repos without exposing the repository. Public or password-protected docs from any repo.">
2128
+ <meta name="generator" content="nrdocs ${NRDOCS_VERSION}">
2129
+ <title>nrdocs \u2014 Publish docs without exposing the repo</title>
2130
+ <style>
2131
+ :root{--bg:#f7f7f4;--surface:#fff;--surface-alt:#efefe9;--text:#171717;--muted:#686860;--line:#deded6;--accent:#1f6feb;--accent-dark:#174ea6;--code-bg:#202124;--code-text:#f5f5f0;--max:1120px;--radius:18px;--shadow:0 18px 50px rgba(20,20,20,0.08)}
2132
+ *{box-sizing:border-box}
2133
+ html{scroll-behavior:smooth}
2134
+ body{margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
2135
+ a{color:inherit;text-decoration:none}
2136
+ .site-header{position:sticky;top:0;z-index:20;border-bottom:1px solid rgba(222,222,214,0.8);background:rgba(247,247,244,0.88);backdrop-filter:blur(16px)}
2137
+ .nav{max-width:var(--max);margin:0 auto;padding:18px 24px;display:flex;align-items:center;justify-content:space-between;gap:24px}
2138
+ .brand{display:inline-flex;align-items:center;gap:10px;font-weight:720;letter-spacing:-0.03em;font-size:20px}
2139
+ .brand-mark{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;background:var(--text);color:var(--surface);font-size:14px;font-weight:800;letter-spacing:-0.05em}
2140
+ .nav-links{display:flex;align-items:center;gap:22px;color:var(--muted);font-size:14px;font-weight:560}
2141
+ .nav-links a:hover{color:var(--text)}
2142
+ .container{max-width:var(--max);margin:0 auto;padding:0 24px}
2143
+ .hero{padding:86px 0 64px;display:grid;grid-template-columns:minmax(0,1.05fr) minmax(320px,0.95fr);gap:54px;align-items:center}
2144
+ .eyebrow{display:inline-flex;align-items:center;gap:8px;padding:7px 11px;border:1px solid var(--line);border-radius:999px;background:var(--surface);color:var(--muted);font-size:13px;font-weight:620;margin-bottom:22px}
2145
+ .eyebrow span{width:7px;height:7px;border-radius:999px;background:#2da44e}
2146
+ h1,h2,h3,p{margin-top:0}
2147
+ h1{font-size:clamp(46px,7vw,72px);line-height:0.96;letter-spacing:-0.06em;margin-bottom:26px}
2148
+ .hero-copy{font-size:clamp(18px,2vw,21px);color:var(--muted);max-width:660px;margin-bottom:34px}
2149
+ .hero-copy strong{color:var(--text);font-weight:720}
2150
+ .actions{display:flex;flex-wrap:wrap;gap:12px;align-items:center}
2151
+ .button{display:inline-flex;align-items:center;justify-content:center;min-height:46px;padding:0 18px;border-radius:12px;font-weight:700;border:1px solid transparent;transition:transform 160ms ease,background 160ms ease}
2152
+ .button:hover{transform:translateY(-1px)}
2153
+ .button.primary{background:var(--text);color:var(--surface)}
2154
+ .button.primary:hover{background:#2c2c2c}
2155
+ .button.secondary{background:var(--surface);color:var(--text);border-color:var(--line)}
2156
+ .button.secondary:hover{border-color:#c7c7bd}
2157
+ .hero-card{background:var(--surface);border:1px solid var(--line);border-radius:26px;box-shadow:var(--shadow);overflow:hidden}
2158
+ .terminal-top{display:flex;align-items:center;gap:7px;padding:16px 18px;background:var(--surface-alt);border-bottom:1px solid var(--line)}
2159
+ .dot{width:10px;height:10px;border-radius:999px;background:#c9c9c1}
2160
+ .terminal{margin:0;background:var(--code-bg);color:var(--code-text);padding:24px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:14px;line-height:1.75;min-height:220px;overflow-x:auto;white-space:pre}
2161
+ .terminal .muted{color:#a8a8a0}
2162
+ .terminal .ok{color:#9be9a8}
2163
+ .terminal .path{color:#79c0ff}
2164
+ section{padding:68px 0}
2165
+ .section-head{max-width:760px;margin-bottom:34px}
2166
+ .section-kicker{color:var(--accent-dark);font-size:13px;font-weight:800;letter-spacing:0.08em;text-transform:uppercase;margin-bottom:12px}
2167
+ h2{font-size:clamp(32px,5vw,48px);line-height:1.05;letter-spacing:-0.05em;margin-bottom:16px}
2168
+ .section-copy{color:var(--muted);font-size:18px;max-width:720px}
2169
+ .grid{display:grid;gap:18px}
2170
+ .grid.two{grid-template-columns:repeat(2,minmax(0,1fr))}
2171
+ .grid.three{grid-template-columns:repeat(3,minmax(0,1fr))}
2172
+ .grid.four{grid-template-columns:repeat(4,minmax(0,1fr))}
2173
+ .card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:24px}
2174
+ .card h3{font-size:20px;letter-spacing:-0.03em;margin-bottom:10px}
2175
+ .card p{color:var(--muted);margin-bottom:0}
2176
+ .problem-list{margin:1.2rem 0 0;padding-left:1.2rem;color:var(--muted);line-height:1.7}
2177
+ .problem-list li{margin-bottom:0.3rem}
2178
+ .compare{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:28px}
2179
+ .compare-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:24px}
2180
+ .compare-card h3{font-size:16px;margin-bottom:12px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:0.04em}
2181
+ .compare-card pre{background:var(--surface-alt);border-radius:10px;padding:16px;font-size:13px;line-height:1.7;margin:0;white-space:pre;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;color:var(--text)}
2182
+ .workflow{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-top:28px}
2183
+ .step{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:18px;min-height:130px}
2184
+ .step-number{display:inline-grid;place-items:center;width:28px;height:28px;border-radius:9px;background:var(--surface-alt);color:var(--muted);font-size:13px;font-weight:800;margin-bottom:14px}
2185
+ .step strong{display:block;line-height:1.25;letter-spacing:-0.02em;margin-bottom:6px}
2186
+ .step span{color:var(--muted);font-size:14px;line-height:1.45;display:block}
2187
+ .callout{background:var(--text);color:var(--surface);border-radius:28px;padding:44px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:28px;align-items:center;margin-top:48px}
2188
+ .callout h2{color:var(--surface);margin-bottom:12px}
2189
+ .callout p{color:#d6d6cf;max-width:680px;margin-bottom:0;font-size:18px}
2190
+ .callout .button{background:var(--surface);color:var(--text);white-space:nowrap}
2191
+ .status-badge{display:inline-block;padding:5px 10px;border-radius:8px;background:var(--surface-alt);border:1px solid var(--line);font-size:13px;font-weight:600;color:var(--muted);margin-top:12px}
2192
+ .footnote{color:var(--muted);font-size:14px;text-align:center;padding:34px 24px 48px}
2193
+ @media(max-width:900px){.nav-links{display:none}.hero,.compare,.callout{grid-template-columns:1fr}.grid.two,.grid.three,.grid.four,.workflow{grid-template-columns:1fr}.hero{padding-top:60px}.callout{padding:32px}}
2194
+ @media(max-width:520px){.container,.nav{padding-left:18px;padding-right:18px}h1{font-size:42px}.terminal{font-size:12px;padding:18px}.callout{padding:24px}}
2195
+ </style>
2196
+ </head>
2197
+ <body>
2198
+ <header class="site-header">
2199
+ <nav class="nav">
2200
+ <a class="brand" href="/"><span class="brand-mark">nr</span><span>nrdocs</span></a>
2201
+ <div class="nav-links">
2202
+ <a href="#problem">Problem</a>
2203
+ <a href="#workflow">Workflow</a>
2204
+ <a href="#use-cases">Use cases</a>
2205
+ <a href="#features">Features</a>
2206
+ <a href="https://github.com/noam-r/nrdocs">GitHub</a>
2207
+ </div>
2208
+ </nav>
2209
+ </header>
2210
+
2211
+ <main>
2212
+ <div class="container">
2213
+ <section class="hero">
2214
+ <div>
2215
+ <div class="eyebrow"><span></span> Open-source docs publishing</div>
2216
+ <h1>Publish docs without exposing the repo.</h1>
2217
+ <p class="hero-copy">
2218
+ <strong>nrdocs</strong> lets teams keep documentation next to the code, then publish only the generated docs site. Use it for public docs from private repos, password-protected docs, or many small docs sites under one shared domain.
2219
+ </p>
2220
+ <p class="hero-copy" style="font-size:0.95em;margin-bottom:28px">Works with public and private GitHub repositories. Especially useful when the docs need a different visibility policy than the code.</p>
2221
+ <div class="actions">
2222
+ <a class="button primary" href="https://github.com/noam-r/nrdocs">View on GitHub</a>
2223
+ <a class="button secondary" href="https://github.com/noam-r/nrdocs#quick-start">Setup guide</a>
2224
+ </div>
2225
+ </div>
2226
+ <aside class="hero-card">
2227
+ <div class="terminal-top"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
2228
+ <pre class="terminal"><span class="muted"># deploy once</span>
2229
+ nrdocs deploy
2230
+
2231
+ <span class="muted"># repo owner</span>
2232
+ nrdocs init
2233
+ git push
2234
+
2235
+ <span class="muted"># operator approves</span>
2236
+ nrdocs approve owner/repo --access public
2237
+ <span class="ok">\u2713 docs live</span>
2238
+ <span class="path">${env.BASE_URL}/owner/repo/</span></pre>
2239
+ </aside>
2240
+ </section>
2241
+ </div>
2242
+
2243
+ <section id="problem">
2244
+ <div class="container">
2245
+ <div class="section-head">
2246
+ <div class="section-kicker">The problem</div>
2247
+ <h2>GitHub Pages couples docs to repo visibility.</h2>
2248
+ <p class="section-copy">You have a repo where the code must stay private, but the docs should be easy to publish. The usual options are awkward:</p>
2249
+ <ul class="problem-list">
2250
+ <li>Make the repo public</li>
2251
+ <li>Create and maintain a second public docs repo</li>
2252
+ <li>Use enterprise-only private Pages access</li>
2253
+ <li>Give readers access to the source repo</li>
2254
+ </ul>
2255
+ <p class="section-copy" style="margin-top:1.2rem"><strong>nrdocs gives you a cleaner option:</strong> keep docs in the repo, publish only the docs output.</p>
2256
+ </div>
2257
+
2258
+ <div class="compare">
2259
+ <div class="compare-card">
2260
+ <h3>Without nrdocs</h3>
2261
+ <pre>private repo
2262
+ \u2193 copy docs manually
2263
+ public docs repo
2264
+ \u2193 GitHub Pages
2265
+ public docs site</pre>
2266
+ </div>
2267
+ <div class="compare-card">
2268
+ <h3>With nrdocs</h3>
2269
+ <pre>any repo (public or private)
2270
+ \u2193 git push
2271
+ nrdocs (GitHub Actions + OIDC)
2272
+ \u2193
2273
+ public or protected docs site</pre>
2274
+ </div>
2275
+ </div>
2276
+ </div>
2277
+ </section>
2278
+
2279
+ <section id="workflow">
2280
+ <div class="container">
2281
+ <div class="section-head">
2282
+ <div class="section-kicker">How it works</div>
2283
+ <h2>A normal Git workflow for documentation.</h2>
2284
+ <p class="section-copy">An operator deploys nrdocs once. Repo owners initialize docs publishing. GitHub Actions publishes via OIDC. The operator approves. Readers get a clean docs URL. Future pushes update automatically.</p>
2285
+ </div>
2286
+ <div class="workflow">
2287
+ <div class="step"><div class="step-number">1</div><strong>Deploy once</strong><span>Operator runs nrdocs deploy on Cloudflare.</span></div>
2288
+ <div class="step"><div class="step-number">2</div><strong>Init</strong><span>Repo owner runs nrdocs init. Adds Markdown docs.</span></div>
2289
+ <div class="step"><div class="step-number">3</div><strong>Push</strong><span>GitHub Action registers and publishes docs via OIDC.</span></div>
2290
+ <div class="step"><div class="step-number">4</div><strong>Approve</strong><span>Operator decides: public or password-protected.</span></div>
2291
+ <div class="step"><div class="step-number">5</div><strong>Live</strong><span>Docs served. Updates publish without re-approval.</span></div>
2292
+ </div>
2293
+ </div>
2294
+ </section>
2295
+
2296
+ <section id="use-cases">
2297
+ <div class="container">
2298
+ <div class="section-head">
2299
+ <div class="section-kicker">Use cases</div>
2300
+ <h2>Use nrdocs when docs and code need different visibility.</h2>
2301
+ </div>
2302
+ <div class="grid four">
2303
+ <article class="card"><h3>Public docs from a private repo</h3><p>Keep the source private while publishing documentation for users, customers, or the community.</p></article>
2304
+ <article class="card"><h3>Password-protected internal docs</h3><p>Share docs with a small audience without giving readers access to the GitHub repository.</p></article>
2305
+ <article class="card"><h3>Same-repo documentation</h3><p>Keep docs close to the code they describe instead of copying them into a separate publishing repo.</p></article>
2306
+ <article class="card"><h3>Many sites, one platform</h3><p>Run one shared nrdocs instance and approve multiple repositories under a common docs domain.</p></article>
2307
+ </div>
2308
+ </div>
2309
+ </section>
2310
+
2311
+ <section id="audience">
2312
+ <div class="container">
2313
+ <div class="section-head">
2314
+ <div class="section-kicker">Two roles</div>
2315
+ <h2>Built for repo owners and platform operators.</h2>
2316
+ </div>
2317
+ <div class="grid two">
2318
+ <article class="card">
2319
+ <h3>For repo owners</h3>
2320
+ <p>Write Markdown in your repo. Run nrdocs init. Push normally. Get a docs URL after approval. No hosting infrastructure to manage.</p>
2321
+ </article>
2322
+ <article class="card">
2323
+ <h3>For platform operators</h3>
2324
+ <p>Deploy one shared nrdocs instance. Approve which repos may publish. Choose public or password-protected access. Keep platform secrets out of project repos.</p>
2325
+ </article>
2326
+ </div>
2327
+ </div>
2328
+ </section>
2329
+
2330
+ <section id="features">
2331
+ <div class="container">
2332
+ <div class="section-head">
2333
+ <div class="section-kicker">Features</div>
2334
+ <h2>Publish, protect, serve.</h2>
2335
+ </div>
2336
+ <div class="grid three">
2337
+ <article class="card"><h3>No accidental public repos</h3><p>Publish documentation without making the source repository public.</p></article>
2338
+ <article class="card"><h3>Public or password-protected</h3><p>Choose whether each docs site is open to everyone or protected by a shared password.</p></article>
2339
+ <article class="card"><h3>No publishing secrets in repos</h3><p>GitHub OIDC lets repositories publish without handing long-lived platform credentials to repo owners.</p></article>
2340
+ <article class="card"><h3>Operator approval flow</h3><p>Operators control which repositories can publish and under which access policy.</p></article>
2341
+ <article class="card"><h3>Serverless hosting</h3><p>Runs on Cloudflare Workers, D1, and R2. No VM, no container, no maintenance.</p></article>
2342
+ <article class="card"><h3>Markdown-first</h3><p>Start with simple Markdown documentation that lives next to the code.</p></article>
2343
+ </div>
2344
+ </div>
2345
+ </section>
2346
+
2347
+ <section id="not">
2348
+ <div class="container">
2349
+ <div class="grid two">
2350
+ <article class="card">
2351
+ <div class="section-kicker">What nrdocs is not</div>
2352
+ <h3>Not another docs generator</h3>
2353
+ <p>MkDocs, Docusaurus, and similar tools build documentation sites. nrdocs focuses on publishing, routing, protecting, and serving docs that live in GitHub repos. Use the built-in Markdown flow for simple docs today.</p>
2354
+ </article>
2355
+ <article class="card">
2356
+ <div class="section-kicker">Project status</div>
2357
+ <h3>Early open-source release</h3>
2358
+ <p>nrdocs is an early open-source project. The current focus is making same-repo docs publishing smooth for public and private GitHub repositories.</p>
2359
+ <div class="status-badge">v${NRDOCS_VERSION} &middot; early release</div>
2360
+ </article>
2361
+ </div>
2362
+ </div>
2363
+ </section>
2364
+
2365
+ <div class="container">
2366
+ <div class="callout">
2367
+ <div>
2368
+ <h2>Try nrdocs</h2>
2369
+ <p>Deploy once, initialize a repo, approve the site, and publish docs without exposing the source repository.</p>
2370
+ </div>
2371
+ <div class="actions">
2372
+ <a class="button" href="https://github.com/noam-r/nrdocs">View on GitHub</a>
2373
+ </div>
2374
+ </div>
2375
+ </div>
2376
+ </main>
2377
+
2378
+ <footer class="footnote">
2379
+ <p>nrdocs v${NRDOCS_VERSION} &middot; docs should move with the work, not chase it around.</p>
2380
+ </footer>
2381
+ </body>
2382
+ </html>`;
2383
+ return new Response(html, {
2384
+ status: 200,
2385
+ headers: {
2386
+ "Content-Type": "text/html; charset=utf-8",
2387
+ "X-Content-Type-Options": "nosniff",
2388
+ "Cache-Control": "public, max-age=3600"
2389
+ }
2390
+ });
2391
+ }
2392
+
2393
+ // ../worker/src/index.ts
2394
+ var router = new Router();
2395
+ router.get("/api/status", handleStatus);
2396
+ router.get("/api/operator/me", handleOperatorMe);
2397
+ router.get("/api/repos", handleListRepos);
2398
+ router.get("/api/repos/:owner/:repo", handleGetRepo);
2399
+ router.post("/api/repos/:owner/:repo/approve", handleApproveRepo);
2400
+ router.post("/api/repos/:owner/:repo/disable", handleDisableRepo);
2401
+ router.post("/api/repos/:owner/:repo/access", handleSetAccess);
2402
+ router.post("/api/repos/:owner/:repo/password", handleSetPassword);
2403
+ router.post("/api/repos/:owner/:repo/allow-self-password", handleAllowSelfPassword);
2404
+ router.post("/api/repos/:owner/:repo/disallow-self-password", handleDisallowSelfPassword);
2405
+ router.get("/api/auto-approval-rules", handleListRules);
2406
+ router.post("/api/auto-approval-rules", handleCreateRule);
2407
+ router.delete("/api/auto-approval-rules/:id", handleDeleteRule);
2408
+ router.get("/api/static", handleListStatic);
2409
+ router.put("/api/static/:key", handleSetStatic);
2410
+ router.delete("/api/static/:key", handleDeleteStatic);
2411
+ router.post("/api/publish", handlePublish);
2412
+ router.get("/api/audit-log", handleAuditLog);
2413
+ var index_default = {
2414
+ async fetch(request, env) {
2415
+ const requestId = crypto.randomUUID();
2416
+ try {
2417
+ const response = await router.handle(request, env);
2418
+ if (response) return response;
2419
+ const url = new URL(request.url);
2420
+ if (request.method === "POST" && url.pathname === "/_nrdocs/login") {
2421
+ return handlePasswordLogin(request, env);
2422
+ }
2423
+ if (url.pathname === "/" && request.method === "GET") {
2424
+ return handleHomepage(request, env);
2425
+ }
2426
+ if (isReservedPath(url.pathname)) {
2427
+ return jsonError("NOT_FOUND", "Not found", 404);
2428
+ }
2429
+ if (request.method === "GET") {
2430
+ const segments = url.pathname.split("/").filter(Boolean);
2431
+ if (segments.length >= 2) {
2432
+ return handleServe(request, env, url);
2433
+ }
2434
+ }
2435
+ return jsonError("NOT_FOUND", "Endpoint not found", 404);
2436
+ } catch (err) {
2437
+ const message = err instanceof Error ? err.message : "Unknown error";
2438
+ console.error(`[${requestId}] Unhandled error:`, message);
2439
+ return new Response(
2440
+ JSON.stringify({
2441
+ ok: false,
2442
+ error: {
2443
+ code: "INTERNAL_ERROR",
2444
+ message: "An internal error occurred"
2445
+ },
2446
+ request_id: requestId
2447
+ }),
2448
+ {
2449
+ status: 500,
2450
+ headers: { "Content-Type": "application/json" }
2451
+ }
2452
+ );
2453
+ }
2454
+ }
2455
+ };
2456
+ export {
2457
+ index_default as default
2458
+ };