@indecisivedev/openplan 0.2.0-alpha.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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/dist/access-policy.d.ts +35 -0
  4. package/dist/access-policy.js +107 -0
  5. package/dist/access-policy.js.map +1 -0
  6. package/dist/access-store.d.ts +33 -0
  7. package/dist/access-store.js +212 -0
  8. package/dist/access-store.js.map +1 -0
  9. package/dist/admin.d.ts +1 -0
  10. package/dist/admin.js +13 -0
  11. package/dist/admin.js.map +1 -0
  12. package/dist/api.d.ts +2 -0
  13. package/dist/api.js +867 -0
  14. package/dist/api.js.map +1 -0
  15. package/dist/bootstrap-admin.d.ts +1 -0
  16. package/dist/bootstrap-admin.js +35 -0
  17. package/dist/bootstrap-admin.js.map +1 -0
  18. package/dist/browser-security.d.ts +5 -0
  19. package/dist/browser-security.js +53 -0
  20. package/dist/browser-security.js.map +1 -0
  21. package/dist/celld-quickstart.d.ts +51 -0
  22. package/dist/celld-quickstart.js +1175 -0
  23. package/dist/celld-quickstart.js.map +1 -0
  24. package/dist/cli-api-client.d.ts +78 -0
  25. package/dist/cli-api-client.js +272 -0
  26. package/dist/cli-api-client.js.map +1 -0
  27. package/dist/cli-credentials.d.ts +47 -0
  28. package/dist/cli-credentials.js +447 -0
  29. package/dist/cli-credentials.js.map +1 -0
  30. package/dist/cli-endpoint.d.ts +19 -0
  31. package/dist/cli-endpoint.js +56 -0
  32. package/dist/cli-endpoint.js.map +1 -0
  33. package/dist/cli-program.d.ts +11 -0
  34. package/dist/cli-program.js +934 -0
  35. package/dist/cli-program.js.map +1 -0
  36. package/dist/cli.d.ts +2 -0
  37. package/dist/cli.js +7 -0
  38. package/dist/cli.js.map +1 -0
  39. package/dist/client-ip.d.ts +4 -0
  40. package/dist/client-ip.js +49 -0
  41. package/dist/client-ip.js.map +1 -0
  42. package/dist/config.d.ts +35 -0
  43. package/dist/config.js +113 -0
  44. package/dist/config.js.map +1 -0
  45. package/dist/crypto.d.ts +3 -0
  46. package/dist/crypto.js +11 -0
  47. package/dist/crypto.js.map +1 -0
  48. package/dist/database.d.ts +15 -0
  49. package/dist/database.js +155 -0
  50. package/dist/database.js.map +1 -0
  51. package/dist/db.d.ts +31 -0
  52. package/dist/db.js +388 -0
  53. package/dist/db.js.map +1 -0
  54. package/dist/drafts.d.ts +43 -0
  55. package/dist/drafts.js +90 -0
  56. package/dist/drafts.js.map +1 -0
  57. package/dist/feature-settings.d.ts +7 -0
  58. package/dist/feature-settings.js +32 -0
  59. package/dist/feature-settings.js.map +1 -0
  60. package/dist/generated/annotation-client.d.ts +1 -0
  61. package/dist/generated/annotation-client.js +2 -0
  62. package/dist/generated/annotation-client.js.map +1 -0
  63. package/dist/html-policy.d.ts +15 -0
  64. package/dist/html-policy.js +172 -0
  65. package/dist/html-policy.js.map +1 -0
  66. package/dist/ids.d.ts +2 -0
  67. package/dist/ids.js +10 -0
  68. package/dist/ids.js.map +1 -0
  69. package/dist/local-auth.d.ts +36 -0
  70. package/dist/local-auth.js +215 -0
  71. package/dist/local-auth.js.map +1 -0
  72. package/dist/maintenance.d.ts +1 -0
  73. package/dist/maintenance.js +18 -0
  74. package/dist/maintenance.js.map +1 -0
  75. package/dist/oidc.d.ts +39 -0
  76. package/dist/oidc.js +194 -0
  77. package/dist/oidc.js.map +1 -0
  78. package/dist/public-url.d.ts +17 -0
  79. package/dist/public-url.js +96 -0
  80. package/dist/public-url.js.map +1 -0
  81. package/dist/rate-limit.d.ts +25 -0
  82. package/dist/rate-limit.js +55 -0
  83. package/dist/rate-limit.js.map +1 -0
  84. package/dist/reconcile.d.ts +5 -0
  85. package/dist/reconcile.js +18 -0
  86. package/dist/reconcile.js.map +1 -0
  87. package/dist/render-annotations.d.ts +10 -0
  88. package/dist/render-annotations.js +73 -0
  89. package/dist/render-annotations.js.map +1 -0
  90. package/dist/render-web.d.ts +74 -0
  91. package/dist/render-web.js +439 -0
  92. package/dist/render-web.js.map +1 -0
  93. package/dist/render.d.ts +4 -0
  94. package/dist/render.js +85 -0
  95. package/dist/render.js.map +1 -0
  96. package/dist/server.d.ts +1 -0
  97. package/dist/server.js +26 -0
  98. package/dist/server.js.map +1 -0
  99. package/dist/shoo.d.ts +26 -0
  100. package/dist/shoo.js +92 -0
  101. package/dist/shoo.js.map +1 -0
  102. package/dist/sqlite-migrations.d.ts +6 -0
  103. package/dist/sqlite-migrations.js +214 -0
  104. package/dist/sqlite-migrations.js.map +1 -0
  105. package/dist/storage.d.ts +14 -0
  106. package/dist/storage.js +86 -0
  107. package/dist/storage.js.map +1 -0
  108. package/dist/types.d.ts +24 -0
  109. package/dist/types.js +2 -0
  110. package/dist/types.js.map +1 -0
  111. package/dist/upload-state.d.ts +19 -0
  112. package/dist/upload-state.js +118 -0
  113. package/dist/upload-state.js.map +1 -0
  114. package/dist/web-auth.d.ts +33 -0
  115. package/dist/web-auth.js +122 -0
  116. package/dist/web-auth.js.map +1 -0
  117. package/dist/web.d.ts +2 -0
  118. package/dist/web.js +550 -0
  119. package/dist/web.js.map +1 -0
  120. package/package.json +70 -0
package/dist/api.js ADDED
@@ -0,0 +1,867 @@
1
+ import { APPLICATION_CSP } from "./browser-security.js";
2
+ import express from "express";
3
+ import { database } from "./database.js";
4
+ import { contentHash, randomToken, sha256 } from "./crypto.js";
5
+ import { assertBrowserAuthConfigured, browserAuthProvider, config } from "./config.js";
6
+ import { findApiKeyByToken, newEventId, pool, publicUploadAuth, revokeApiKey, withTransaction } from "./db.js";
7
+ import { newDraftId, newInternalId } from "./ids.js";
8
+ import { renderHome, renderNotFound } from "./render.js";
9
+ import { checkRateLimit, createRateLimiter } from "./rate-limit.js";
10
+ import { getHtmlObject, probeStorage, putHtmlObject } from "./storage.js";
11
+ import { finalizeReservedVersion, recordUploadWriteError } from "./upload-state.js";
12
+ import { validateHtml } from "./html-policy.js";
13
+ import { clientIp } from "./client-ip.js";
14
+ import { listAccountDrafts } from "./drafts.js";
15
+ import { registerWebRoutes } from "./web.js";
16
+ import { renderSharePassword } from "./render-web.js";
17
+ import { readWebSession } from "./local-auth.js";
18
+ import { getDraftIdFromHost, getDraftPublicUrl, getDraftRawUrl, getHomeUrl, getRequestBaseUrl } from "./public-url.js";
19
+ import { canAccountReadDraft, createDefaultPolicy, findDraftAccessTarget, getDraftAccessOverview, setAccessPolicy } from "./access-store.js";
20
+ import { createUnlockToken, hasValidUnlock, unlockCookie, verifyPassword } from "./access-policy.js";
21
+ import { getBrowserLocalAnnotationsSetting } from "./feature-settings.js";
22
+ import { annotationViewerContentSecurityPolicy, renderAnnotationViewer } from "./render-annotations.js";
23
+ import { ANNOTATION_CLIENT } from "./generated/annotation-client.js";
24
+ class HttpError extends Error {
25
+ statusCode;
26
+ constructor(message, statusCode) {
27
+ super(message);
28
+ this.statusCode = statusCode;
29
+ }
30
+ }
31
+ export function createApp() {
32
+ const app = express();
33
+ app.set("trust proxy", config.trustedProxies);
34
+ const uploadIpRateLimit = createRateLimiter({
35
+ windowMs: Number(process.env.UPLOAD_IP_RATE_LIMIT_WINDOW_MS || 60_000),
36
+ max: Number(process.env.UPLOAD_IP_RATE_LIMIT_MAX || 60),
37
+ keyPrefix: "upload-ip",
38
+ key: (req) => clientIp(req) || "anonymous"
39
+ });
40
+ const uploadKeyRateLimit = createRateLimiter({
41
+ windowMs: Number(process.env.UPLOAD_RATE_LIMIT_WINDOW_MS || 60_000),
42
+ max: Number(process.env.UPLOAD_RATE_LIMIT_MAX || 30),
43
+ keyPrefix: "upload-key",
44
+ key: (req) => req.auth?.id || clientIp(req) || "anonymous"
45
+ });
46
+ // Scoped to /api so that draft GETs carrying a stray JSON body (some HTTP
47
+ // clients always send Content-Type: application/json) can never fail with a
48
+ // body-parser error instead of the draft HTML.
49
+ app.use(noStoreHeaders);
50
+ app.use("/api", express.json({ limit: process.env.UPLOAD_BODY_LIMIT || "2mb" }));
51
+ app.get("/assets/openplan-annotations.v1.js", async (_req, res, next) => {
52
+ try {
53
+ if (!(await getBrowserLocalAnnotationsSetting()).enabled)
54
+ return next();
55
+ res
56
+ .setHeader("Cache-Control", "public, max-age=31536000, immutable")
57
+ .type("application/javascript")
58
+ .send(ANNOTATION_CLIENT);
59
+ }
60
+ catch (error) {
61
+ next(error);
62
+ }
63
+ });
64
+ app.get("/", async (req, res, next) => {
65
+ try {
66
+ const draftId = getDraftIdFromRequest(req);
67
+ if (draftId) {
68
+ await renderDraft(req, res, { draftId });
69
+ return;
70
+ }
71
+ res.type("html").send(renderHome({ publicBaseUrl: getHomeUrlForRequest(req) }));
72
+ }
73
+ catch (error) {
74
+ next(error);
75
+ }
76
+ });
77
+ app.get("/healthz", async (req, res) => {
78
+ try {
79
+ await pool.query("SELECT 1");
80
+ res.json({
81
+ ok: true,
82
+ runtime: "node",
83
+ profile: config.runtimeProfile,
84
+ database: database.kind
85
+ });
86
+ }
87
+ catch (error) {
88
+ console.error("openplan health check failed", { requestId: res.getHeader("X-Openplan-Request-Id"), error });
89
+ res.status(503).json({ ok: false, error: "Service health check failed." });
90
+ }
91
+ });
92
+ app.get("/readyz", async (_req, res) => {
93
+ const connectors = {
94
+ metadata: database.kind,
95
+ objects: "s3-compatible",
96
+ identity: browserAuthProvider() === "local" ? "local-password" : browserAuthProvider(),
97
+ sessions: "signed-cookie",
98
+ maintenance: config.reconcileIntervalMs > 0 ? "scheduled" : "manual"
99
+ };
100
+ try {
101
+ await pool.query("SELECT 1");
102
+ await probeStorage();
103
+ if (config.sessionSecret && config.publicBaseUrl)
104
+ assertBrowserAuthConfigured();
105
+ if (browserAuthProvider() === "local") {
106
+ const accounts = await pool.query("SELECT COUNT(*) AS count FROM local_credentials");
107
+ if (Number(accounts.rows[0]?.count || 0) < 1) {
108
+ throw new Error("No local administrator has been bootstrapped.");
109
+ }
110
+ }
111
+ res.json({ ok: true, runtime: "node", profile: config.runtimeProfile, connectors });
112
+ }
113
+ catch (error) {
114
+ console.error("openplan readiness check failed", { requestId: res.getHeader("X-Openplan-Request-Id"), error });
115
+ res.status(503).json({
116
+ ok: false,
117
+ runtime: "node",
118
+ profile: config.runtimeProfile,
119
+ connectors,
120
+ error: "Service is not ready."
121
+ });
122
+ }
123
+ });
124
+ app.get("/api/me", requireAuth, (req, res) => {
125
+ res.json({
126
+ accountId: req.auth.account_id,
127
+ accountName: req.auth.account_name,
128
+ apiKeyId: req.auth.id,
129
+ apiKeyName: req.auth.name
130
+ });
131
+ });
132
+ // The "my docs" feed — shared with the dashboard (src/drafts.js).
133
+ app.get("/api/drafts", requireAuth, async (req, res, next) => {
134
+ try {
135
+ const drafts = await listAccountDrafts(req.auth.account_id, {
136
+ requestBaseUrl: getRequestBaseUrl(req)
137
+ });
138
+ res.json({ ok: true, drafts });
139
+ }
140
+ catch (error) {
141
+ next(error);
142
+ }
143
+ });
144
+ app.get("/api/drafts/:draftId/access", requireAuth, async (req, res, next) => {
145
+ try {
146
+ const overview = await getDraftAccessOverview(req.auth.account_id, routeParam(req, "draftId"));
147
+ if (!overview)
148
+ return res.status(404).json({ ok: false, error: "Draft not found." });
149
+ res.json({ ok: true, access: overview.plan });
150
+ }
151
+ catch (error) {
152
+ next(error);
153
+ }
154
+ });
155
+ app.put("/api/drafts/:draftId/access", requireAuth, async (req, res, next) => {
156
+ try {
157
+ const mutation = accessMutation(req.body, false);
158
+ requireModeConfiguration(mutation.mode);
159
+ if (mutation.mode === "password" && !config.shareSecret) {
160
+ throw new HttpError("Password sharing is not configured.", 503);
161
+ }
162
+ const access = await setAccessPolicy({
163
+ accountId: req.auth.account_id,
164
+ apiKeyId: req.auth.id,
165
+ draftId: routeParam(req, "draftId"),
166
+ ...mutation,
167
+ requestId: cleanText(req.get("x-request-id"))
168
+ });
169
+ if (!access)
170
+ return res.status(404).json({ ok: false, error: "Draft not found." });
171
+ res.json({ ok: true, access });
172
+ }
173
+ catch (error) {
174
+ next(error);
175
+ }
176
+ });
177
+ app.get("/api/drafts/:draftId/versions/:versionNumber/access", requireAuth, async (req, res, next) => {
178
+ try {
179
+ const versionNumber = positiveVersion(req.params.versionNumber);
180
+ const overview = await getDraftAccessOverview(req.auth.account_id, routeParam(req, "draftId"));
181
+ const access = overview?.versions.get(versionNumber);
182
+ if (!access)
183
+ return res.status(404).json({ ok: false, error: "Draft version not found." });
184
+ res.json({ ok: true, access });
185
+ }
186
+ catch (error) {
187
+ next(error);
188
+ }
189
+ });
190
+ app.put("/api/drafts/:draftId/versions/:versionNumber/access", requireAuth, async (req, res, next) => {
191
+ try {
192
+ const mutation = accessMutation(req.body, true);
193
+ requireModeConfiguration(mutation.mode);
194
+ if (mutation.mode === "password" && !config.shareSecret) {
195
+ throw new HttpError("Password sharing is not configured.", 503);
196
+ }
197
+ const access = await setAccessPolicy({
198
+ accountId: req.auth.account_id,
199
+ apiKeyId: req.auth.id,
200
+ draftId: routeParam(req, "draftId"),
201
+ versionNumber: positiveVersion(req.params.versionNumber),
202
+ ...mutation,
203
+ requestId: cleanText(req.get("x-request-id"))
204
+ });
205
+ if (!access)
206
+ return res.status(404).json({ ok: false, error: "Draft version not found." });
207
+ res.json({ ok: true, access });
208
+ }
209
+ catch (error) {
210
+ next(error);
211
+ }
212
+ });
213
+ app.post("/api/api-keys", requireAuth, async (req, res, next) => {
214
+ try {
215
+ const token = `op_${randomToken(32)}`;
216
+ const apiKeyId = newInternalId();
217
+ const name = cleanText(req.body?.name) || "CLI API Key";
218
+ const limit = checkRateLimit(`key-mint:${req.auth.account_id}`, 3_600_000, 10);
219
+ if (!limit.allowed) {
220
+ res.setHeader("Retry-After", String(limit.retryAfter));
221
+ return res.status(429).json({ ok: false, error: "Too many API keys were created." });
222
+ }
223
+ await withTransaction(async (client) => {
224
+ await client.query("SELECT id FROM accounts WHERE id = $1 FOR UPDATE", [req.auth.account_id]);
225
+ const result = await client.query(`
226
+ INSERT INTO api_keys (id, account_id, name, key_hash, parent_api_key_id)
227
+ SELECT $1, $2, $3, $4, id FROM api_keys
228
+ WHERE id = $5 AND account_id = $2 AND revoked_at IS NULL AND key_hash = $6
229
+ RETURNING id
230
+ `, [apiKeyId, req.auth.account_id, name, sha256(token), req.auth.id, req.auth.keyHash]);
231
+ if (!result.rowCount)
232
+ throw new HttpError("API key has been revoked.", 401);
233
+ });
234
+ res.status(201).json({
235
+ ok: true,
236
+ apiKey: {
237
+ id: apiKeyId,
238
+ name
239
+ },
240
+ token
241
+ });
242
+ }
243
+ catch (error) {
244
+ next(error);
245
+ }
246
+ });
247
+ app.post("/api/api-keys/:apiKeyId/revoke", requireAuth, async (req, res, next) => {
248
+ try {
249
+ const revoked = await revokeApiKey(req.auth.account_id, routeParam(req, "apiKeyId"));
250
+ if (!revoked) {
251
+ return res.status(404).json({ ok: false, error: "API key not found." });
252
+ }
253
+ res.json({ ok: true });
254
+ }
255
+ catch (error) {
256
+ next(error);
257
+ }
258
+ });
259
+ app.post("/api/uploads", uploadIpRateLimit, optionalUploadAuth, uploadKeyRateLimit, async (req, res, next) => {
260
+ try {
261
+ const { html, filename, metadata = {}, draftId, description } = req.body || {};
262
+ const validation = validateHtml(html, { maxBytes: config.maxHtmlBytes });
263
+ if (!validation.ok) {
264
+ return res.status(422).json({
265
+ ok: false,
266
+ errors: validation.errors,
267
+ warnings: validation.warnings
268
+ });
269
+ }
270
+ const byteLength = Buffer.byteLength(html, "utf8");
271
+ const nowHash = contentHash(html);
272
+ const sourceIp = clientIp(req);
273
+ // Railway's edge sets X-Railway-Request-Id and documents it for correlating
274
+ // against network logs, so we store it verbatim rather than minting our own.
275
+ const requestId = cleanText(req.get("x-railway-request-id"));
276
+ const stats = validation.stats || { hasInlineScript: false, externalImageHosts: [] };
277
+ const result = await withTransaction(async (client) => {
278
+ const existingDraft = draftId
279
+ ? await findOwnedDraft(client, draftId, req.auth.account_id)
280
+ : null;
281
+ if (draftId && !existingDraft) {
282
+ throw new HttpError("Draft not found.", 404);
283
+ }
284
+ const draft = existingDraft || {
285
+ id: newDraftId(),
286
+ account_id: req.auth.account_id
287
+ };
288
+ const versionNumber = existingDraft
289
+ ? Number((await client.query("SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version FROM draft_versions WHERE draft_id = $1", [draft.id])).rows[0].next_version)
290
+ : 1;
291
+ const versionId = newInternalId();
292
+ const objectKey = `drafts/${draft.id}/versions/${versionId}.html`;
293
+ const title = validation.title || existingDraft?.title || filename || "Untitled Draft";
294
+ const draftMetadata = {
295
+ title,
296
+ description: cleanText(description, 1000),
297
+ repoOrg: cleanText(metadata.repoOrg),
298
+ repoName: cleanText(metadata.repoName),
299
+ repoHost: cleanText(metadata.repoHost),
300
+ eventType: existingDraft ? "draft.updated" : "draft.created",
301
+ requestMetadata: metadata
302
+ };
303
+ if (!existingDraft) {
304
+ const defaultAccessPolicyId = await createDefaultPolicy(client, req.auth.account_id, req.auth.id === publicUploadAuth.id ? "public" : "private");
305
+ await client.query(`
306
+ INSERT INTO drafts (
307
+ id, account_id, title, description, repo_org, repo_name, repo_host,
308
+ default_access_policy_id
309
+ )
310
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
311
+ `, [
312
+ draft.id,
313
+ req.auth.account_id,
314
+ title,
315
+ draftMetadata.description,
316
+ draftMetadata.repoOrg,
317
+ draftMetadata.repoName,
318
+ draftMetadata.repoHost,
319
+ defaultAccessPolicyId
320
+ ]);
321
+ }
322
+ await client.query(`
323
+ INSERT INTO draft_versions (
324
+ id, draft_id, version_number, object_key, content_hash, file_size,
325
+ created_by_api_key_id, source_ip, user_agent, cli_version,
326
+ git_branch, git_commit_sha, original_filename,
327
+ git_commit_subject, git_dirty, request_id, has_inline_script,
328
+ external_image_hosts, ci_run_url, ci_actor, status, draft_metadata_json
329
+ )
330
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
331
+ $14, $15, $16, $17, $18, $19, $20, $21, $22)
332
+ `, [
333
+ versionId,
334
+ draft.id,
335
+ versionNumber,
336
+ objectKey,
337
+ nowHash,
338
+ byteLength,
339
+ req.auth.id,
340
+ sourceIp,
341
+ req.get("user-agent") || null,
342
+ cleanText(metadata.cliVersion),
343
+ cleanText(metadata.gitBranch),
344
+ cleanText(metadata.gitCommitSha),
345
+ cleanText(filename),
346
+ cleanText(metadata.gitCommitSubject),
347
+ typeof metadata.gitDirty === "boolean" ? metadata.gitDirty : null,
348
+ requestId,
349
+ stats.hasInlineScript,
350
+ JSON.stringify(stats.externalImageHosts || []),
351
+ cleanText(metadata.ciRunUrl),
352
+ cleanText(metadata.ciActor),
353
+ "pending",
354
+ draftMetadata
355
+ ]);
356
+ await client.query(`
357
+ INSERT INTO upload_events (
358
+ id, draft_id, draft_version_id, api_key_id, event_type,
359
+ source_ip, user_agent, metadata_json
360
+ )
361
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
362
+ `, [
363
+ newEventId(),
364
+ draft.id,
365
+ versionId,
366
+ req.auth.id,
367
+ "upload.reserved",
368
+ sourceIp,
369
+ req.get("user-agent") || null,
370
+ metadata
371
+ ]);
372
+ return {
373
+ draftId: draft.id,
374
+ versionId,
375
+ versionNumber,
376
+ title,
377
+ requestId,
378
+ publicUrl: getDraftPublicUrl({
379
+ draftId: draft.id,
380
+ publicBaseUrl: config.publicBaseUrl,
381
+ requestBaseUrl: getRequestBaseUrl(req)
382
+ }),
383
+ rawUrl: getDraftRawUrl({
384
+ draftId: draft.id,
385
+ publicBaseUrl: config.publicBaseUrl,
386
+ requestBaseUrl: getRequestBaseUrl(req)
387
+ }),
388
+ warnings: validation.warnings
389
+ };
390
+ });
391
+ try {
392
+ await putHtmlObject(`drafts/${result.draftId}/versions/${result.versionId}.html`, html);
393
+ }
394
+ catch (error) {
395
+ await recordUploadWriteError(result.versionId, error);
396
+ throw error;
397
+ }
398
+ if (!await finalizeReservedVersion(result.versionId)) {
399
+ throw new HttpError("Upload could not be finalized.", 503);
400
+ }
401
+ // Read persisted policy after finalization: an owner may have changed it
402
+ // while the object write was in flight. Never infer sharing from the URL.
403
+ const overview = await getDraftAccessOverview(req.auth.account_id, result.draftId);
404
+ if (!overview)
405
+ throw new HttpError("Uploaded draft is unavailable.", 503);
406
+ res.status(draftId ? 200 : 201).json({
407
+ ok: true, ...result,
408
+ access: overview.versions.get(result.versionNumber),
409
+ planAccess: overview.plan,
410
+ versionUrl: `${getHomeUrlForRequest(req)}/d/${result.draftId}/v/${result.versionNumber}`,
411
+ versionRawUrl: `${getHomeUrlForRequest(req)}/d/${result.draftId}/v/${result.versionNumber}/raw`
412
+ });
413
+ }
414
+ catch (error) {
415
+ next(error);
416
+ }
417
+ });
418
+ app.delete("/api/drafts/:draftId", requireAuth, async (req, res, next) => {
419
+ try {
420
+ const result = await pool.query(`
421
+ UPDATE drafts
422
+ SET deleted_at = now(), updated_at = now()
423
+ WHERE id = $1
424
+ AND account_id = $2
425
+ AND deleted_at IS NULL
426
+ RETURNING id
427
+ `, [req.params.draftId, req.auth.account_id]);
428
+ if (!result.rowCount) {
429
+ return res.status(404).json({ ok: false, error: "Draft not found." });
430
+ }
431
+ res.json({ ok: true });
432
+ }
433
+ catch (error) {
434
+ next(error);
435
+ }
436
+ });
437
+ app.post("/api/drafts/:draftId/disable", requireAuth, async (req, res, next) => {
438
+ try {
439
+ const reason = cleanText(req.body?.reason) || "Disabled by owner.";
440
+ const result = await pool.query(`
441
+ UPDATE drafts
442
+ SET disabled_at = now(), disabled_reason = $3, updated_at = now()
443
+ WHERE id = $1
444
+ AND account_id = $2
445
+ AND deleted_at IS NULL
446
+ RETURNING id
447
+ `, [req.params.draftId, req.auth.account_id, reason]);
448
+ if (!result.rowCount) {
449
+ return res.status(404).json({ ok: false, error: "Draft not found." });
450
+ }
451
+ res.json({ ok: true });
452
+ }
453
+ catch (error) {
454
+ next(error);
455
+ }
456
+ });
457
+ registerWebRoutes(app);
458
+ const unlockBody = [
459
+ express.json({ limit: "8kb" }),
460
+ express.urlencoded({ extended: false, limit: "8kb" })
461
+ ];
462
+ app.post("/unlock", ...unlockBody, async (req, res, next) => {
463
+ try {
464
+ const draftId = getDraftIdFromRequest(req);
465
+ if (!draftId)
466
+ return next();
467
+ await unlockDraft(req, res, { draftId });
468
+ }
469
+ catch (error) {
470
+ next(error);
471
+ }
472
+ });
473
+ app.post("/v/:versionNumber/unlock", ...unlockBody, async (req, res, next) => {
474
+ try {
475
+ const draftId = getDraftIdFromRequest(req);
476
+ if (!draftId)
477
+ return next();
478
+ await unlockDraft(req, res, { draftId, versionNumber: Number(req.params.versionNumber) });
479
+ }
480
+ catch (error) {
481
+ next(error);
482
+ }
483
+ });
484
+ app.post("/d/:draftId/unlock", ...unlockBody, async (req, res, next) => {
485
+ try {
486
+ await unlockDraft(req, res, { draftId: routeParam(req, "draftId") });
487
+ }
488
+ catch (error) {
489
+ next(error);
490
+ }
491
+ });
492
+ app.post("/d/:draftId/v/:versionNumber/unlock", ...unlockBody, async (req, res, next) => {
493
+ try {
494
+ await unlockDraft(req, res, {
495
+ draftId: routeParam(req, "draftId"),
496
+ versionNumber: Number(req.params.versionNumber)
497
+ });
498
+ }
499
+ catch (error) {
500
+ next(error);
501
+ }
502
+ });
503
+ // Every draft URL serves the raw uploaded HTML. The `/raw` aliases are kept
504
+ // because the upload API and CLI hand them out as the canonical agent URL.
505
+ const serveCurrent = async (req, res, next) => {
506
+ try {
507
+ const draftId = getDraftIdFromRequest(req);
508
+ if (!draftId) {
509
+ next();
510
+ return;
511
+ }
512
+ await renderDraft(req, res, { draftId });
513
+ }
514
+ catch (error) {
515
+ next(error);
516
+ }
517
+ };
518
+ const serveVersion = async (req, res, next) => {
519
+ try {
520
+ const draftId = getDraftIdFromRequest(req);
521
+ if (!draftId) {
522
+ next();
523
+ return;
524
+ }
525
+ await renderDraft(req, res, {
526
+ draftId,
527
+ versionNumber: Number(req.params.versionNumber)
528
+ });
529
+ }
530
+ catch (error) {
531
+ next(error);
532
+ }
533
+ };
534
+ const annotateCurrent = async (req, res, next) => {
535
+ try {
536
+ const draftId = getDraftIdFromRequest(req);
537
+ if (!draftId)
538
+ return next();
539
+ await renderAnnotations(req, res, { draftId });
540
+ }
541
+ catch (error) {
542
+ next(error);
543
+ }
544
+ };
545
+ const annotateVersion = async (req, res, next) => {
546
+ try {
547
+ const draftId = getDraftIdFromRequest(req);
548
+ if (!draftId)
549
+ return next();
550
+ await renderAnnotations(req, res, {
551
+ draftId,
552
+ versionNumber: Number(req.params.versionNumber)
553
+ });
554
+ }
555
+ catch (error) {
556
+ next(error);
557
+ }
558
+ };
559
+ app.get("/annotate", annotateCurrent);
560
+ app.get("/v/:versionNumber/annotate", annotateVersion);
561
+ app.get("/raw", serveCurrent);
562
+ app.get("/v/:versionNumber", serveVersion);
563
+ app.get("/v/:versionNumber/raw", serveVersion);
564
+ app.get(["/d/:draftId", "/d/:draftId/raw"], async (req, res, next) => {
565
+ try {
566
+ await renderDraft(req, res, { draftId: routeParam(req, "draftId") });
567
+ }
568
+ catch (error) {
569
+ next(error);
570
+ }
571
+ });
572
+ app.get("/d/:draftId/annotate", async (req, res, next) => {
573
+ try {
574
+ await renderAnnotations(req, res, { draftId: routeParam(req, "draftId") });
575
+ }
576
+ catch (error) {
577
+ next(error);
578
+ }
579
+ });
580
+ app.get("/d/:draftId/v/:versionNumber/annotate", async (req, res, next) => {
581
+ try {
582
+ await renderAnnotations(req, res, {
583
+ draftId: routeParam(req, "draftId"),
584
+ versionNumber: Number(req.params.versionNumber)
585
+ });
586
+ }
587
+ catch (error) {
588
+ next(error);
589
+ }
590
+ });
591
+ app.get(["/d/:draftId/v/:versionNumber", "/d/:draftId/v/:versionNumber/raw"], async (req, res, next) => {
592
+ try {
593
+ await renderDraft(req, res, {
594
+ draftId: routeParam(req, "draftId"),
595
+ versionNumber: Number(req.params.versionNumber)
596
+ });
597
+ }
598
+ catch (error) {
599
+ next(error);
600
+ }
601
+ });
602
+ app.use((req, res) => {
603
+ res.status(404).type("html").send(renderNotFound());
604
+ });
605
+ const errorHandler = (error, _req, res, _next) => {
606
+ const status = error instanceof HttpError ? error.statusCode : 500;
607
+ const message = status >= 500 ? "Internal server error." : errorMessage(error);
608
+ if (status >= 500) {
609
+ console.error(error);
610
+ }
611
+ res.status(status).json({ ok: false, error: message });
612
+ };
613
+ app.use(errorHandler);
614
+ return app;
615
+ }
616
+ // Serve the exact uploaded HTML, byte for byte, to EVERY client — browsers,
617
+ // curl, and agent fetchers alike. There is deliberately no browser detection,
618
+ // iframe wrapper, or consent interstitial: any heuristic that tried to tell an
619
+ // agent from a browser inevitably misclassified some agent fetcher and hid the
620
+ // draft content the service exists to share. A draft URL is now just its HTML.
621
+ async function renderDraft(req, res, { draftId, versionNumber }) {
622
+ const target = await authorizeDraftRead(req, res, draftId, versionNumber);
623
+ if (!target)
624
+ return;
625
+ const html = await getHtmlObject(target.objectKey);
626
+ res.setHeader("Content-Security-Policy", draftContentSecurityPolicy());
627
+ if (target.mode !== "public") {
628
+ res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
629
+ }
630
+ res.setHeader("X-Openplan-Draft-Id", target.draftId);
631
+ res.setHeader("X-Openplan-Draft-Version", String(target.versionNumber));
632
+ res.type("html").send(html);
633
+ }
634
+ async function renderAnnotations(req, res, { draftId, versionNumber }) {
635
+ if (!(await getBrowserLocalAnnotationsSetting()).enabled) {
636
+ return res.status(404).type("html").send(renderNotFound());
637
+ }
638
+ const target = await authorizeDraftRead(req, res, draftId, versionNumber);
639
+ if (!target)
640
+ return;
641
+ const wildcard = !req.path.startsWith("/d/");
642
+ const frameSrc = wildcard
643
+ ? versionNumber === undefined ? "/raw" : `/v/${target.versionNumber}/raw`
644
+ : versionNumber === undefined
645
+ ? `/d/${encodeURIComponent(draftId)}/raw`
646
+ : `/d/${encodeURIComponent(draftId)}/v/${target.versionNumber}/raw`;
647
+ res.setHeader("Content-Security-Policy", annotationViewerContentSecurityPolicy());
648
+ res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
649
+ res.type("html").send(renderAnnotationViewer({
650
+ title: target.title,
651
+ frameSrc,
652
+ draftId: target.draftId,
653
+ versionId: target.versionId,
654
+ versionNumber: target.versionNumber,
655
+ contentHash: target.contentHash
656
+ }));
657
+ }
658
+ async function authorizeDraftRead(req, res, draftId, versionNumber) {
659
+ if (versionNumber !== undefined && (!Number.isInteger(versionNumber) || versionNumber < 1)) {
660
+ res.status(404).type("html").send(renderNotFound());
661
+ return null;
662
+ }
663
+ const target = await findDraftAccessTarget(draftId, versionNumber);
664
+ if (!target) {
665
+ res.status(404).type("html").send(renderNotFound());
666
+ return null;
667
+ }
668
+ // Browser sessions/unlock cookies are host-only on the application apex.
669
+ // Protected wildcard URLs must reach that origin, never a dead subdomain
670
+ // sign-in route, and never require widening the session cookie Domain.
671
+ if (target.mode !== "public" && getDraftIdFromHost({
672
+ publicBaseUrl: config.publicBaseUrl, host: req.get("host")
673
+ })) {
674
+ const suffix = req.path.endsWith("/raw") ? "/raw" : req.path.endsWith("/annotate") ? "/annotate" : "";
675
+ const versionPath = versionNumber === undefined ? "" : `/v/${versionNumber}`;
676
+ res.redirect(302, `${getHomeUrlForRequest(req)}/d/${draftId}${versionPath}${suffix}`);
677
+ return null;
678
+ }
679
+ if (target.mode === "authenticated" || target.mode === "private") {
680
+ if (!config.sessionSecret || !config.publicBaseUrl) {
681
+ throw new HttpError("Private sharing requires web sign-in configuration.", 503);
682
+ }
683
+ const session = await readWebSession(req);
684
+ if (!session) {
685
+ res.redirect(302, `/auth/sign-in?next=${encodeURIComponent(req.originalUrl)}`);
686
+ return null;
687
+ }
688
+ if (target.mode === "private" && !await canAccountReadDraft(session.accountId, target.ownerAccountId)) {
689
+ res.status(404).type("html").send(renderNotFound());
690
+ return null;
691
+ }
692
+ }
693
+ else if (target.mode === "password") {
694
+ if (!target.policy || !config.shareSecret) {
695
+ throw new HttpError("Password sharing is not configured.", 503);
696
+ }
697
+ if (!hasValidUnlock(req, target.policy, draftId, target.versionId, versionNumber)) {
698
+ res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
699
+ res.status(401).type("html").send(renderSharePassword({
700
+ title: "Protected plan",
701
+ action: unlockAction(req, draftId, versionNumber)
702
+ }));
703
+ return null;
704
+ }
705
+ }
706
+ return target;
707
+ }
708
+ // A CSP is the only thing kept from the old serving path. It never alters the
709
+ // bytes a curl/agent client reads, so it does not gate content in any way; it
710
+ // only constrains what the page may do if a human opens it in a browser —
711
+ // blocking script execution, cross-origin network requests, and form posts.
712
+ // Uploaded drafts are already external-script/-form/-iframe free (see
713
+ // validateHtml) and live on isolated per-draft origins.
714
+ function draftContentSecurityPolicy() {
715
+ return [
716
+ "default-src 'none'",
717
+ "script-src 'none'",
718
+ "style-src 'unsafe-inline'",
719
+ "img-src https: data:",
720
+ "connect-src 'none'",
721
+ "base-uri 'none'",
722
+ "form-action 'none'"
723
+ ].join("; ");
724
+ }
725
+ async function unlockDraft(req, res, { draftId, versionNumber }) {
726
+ const target = await findDraftAccessTarget(draftId, versionNumber);
727
+ if (!target || target.mode !== "password" || !target.policy) {
728
+ return res.status(404).type("html").send(renderNotFound());
729
+ }
730
+ if (!config.shareSecret)
731
+ throw new HttpError("Password sharing is not configured.", 503);
732
+ const source = clientIp(req) || "anonymous";
733
+ for (const bucket of [`share-ip:${source}`, `share-target:${draftId}:${versionNumber ?? "current"}`]) {
734
+ const limit = checkRateLimit(bucket, 15 * 60_000, 20);
735
+ if (!limit.allowed) {
736
+ res.setHeader("Retry-After", String(limit.retryAfter));
737
+ return res.status(429).type("html").send(renderSharePassword({
738
+ title: "Protected plan",
739
+ action: unlockAction(req, draftId, versionNumber),
740
+ error: "Too many attempts. Please try again later."
741
+ }));
742
+ }
743
+ }
744
+ const password = typeof req.body?.password === "string" ? req.body.password : "";
745
+ if (!verifyPassword(password, target.policy)) {
746
+ return res.status(403).type("html").send(renderSharePassword({
747
+ title: "Protected plan",
748
+ action: unlockAction(req, draftId, versionNumber),
749
+ error: "That password is incorrect."
750
+ }));
751
+ }
752
+ const token = createUnlockToken(target.policy, draftId, target.versionId);
753
+ if ((req.get("content-type") || "").toLowerCase().includes("application/json")) {
754
+ return res.json({ ok: true, token, expiresIn: 12 * 60 * 60 });
755
+ }
756
+ res.append("Set-Cookie", unlockCookie(token, draftId, versionNumber));
757
+ return res.redirect(303, canonicalDraftPath(draftId, versionNumber, Boolean(getDraftIdFromRequest(req))));
758
+ }
759
+ function unlockAction(req, draftId, versionNumber) {
760
+ return getDraftIdFromRequest(req)
761
+ ? versionNumber === undefined ? "/unlock" : `/v/${versionNumber}/unlock`
762
+ : versionNumber === undefined
763
+ ? `/d/${draftId}/unlock`
764
+ : `/d/${draftId}/v/${versionNumber}/unlock`;
765
+ }
766
+ function canonicalDraftPath(draftId, versionNumber, wildcard) {
767
+ if (wildcard)
768
+ return versionNumber === undefined ? "/" : `/v/${versionNumber}`;
769
+ return versionNumber === undefined ? `/d/${draftId}` : `/d/${draftId}/v/${versionNumber}`;
770
+ }
771
+ function accessMutation(body, allowInherit) {
772
+ if (!body || typeof body !== "object")
773
+ throw new HttpError("Invalid access policy body.", 400);
774
+ const input = body;
775
+ if (allowInherit && input.inherit === true)
776
+ return { inherit: true };
777
+ const mode = input.mode;
778
+ if (mode !== "private" && mode !== "password" && mode !== "public") {
779
+ throw new HttpError("Access mode must be private, password, or public.", 400);
780
+ }
781
+ const password = typeof input.password === "string" ? input.password : undefined;
782
+ if (mode === "password" && (!password || password.length < 8 || password.length > 512)) {
783
+ throw new HttpError("Password must be between 8 and 512 characters.", 400);
784
+ }
785
+ return password === undefined ? { mode } : { mode, password };
786
+ }
787
+ function positiveVersion(value) {
788
+ const parsed = Number(value);
789
+ if (!Number.isInteger(parsed) || parsed < 1)
790
+ throw new HttpError("Invalid version number.", 400);
791
+ return parsed;
792
+ }
793
+ function requireModeConfiguration(mode) {
794
+ if (mode === "private" && (!config.sessionSecret || !config.publicBaseUrl)) {
795
+ throw new HttpError("Private sharing requires web sign-in configuration.", 503);
796
+ }
797
+ }
798
+ async function findOwnedDraft(client, draftId, accountId) {
799
+ const result = await client.query(`
800
+ SELECT *
801
+ FROM drafts
802
+ WHERE id = $1
803
+ AND account_id = $2
804
+ AND deleted_at IS NULL
805
+ LIMIT 1
806
+ `, [draftId, accountId]);
807
+ return result.rows[0] || null;
808
+ }
809
+ async function requireAuth(req, res, next) {
810
+ const auth = await optionalAuth(req);
811
+ if (!auth) {
812
+ return res.status(401).json({ ok: false, error: "Missing or invalid API key." });
813
+ }
814
+ req.auth = auth;
815
+ next();
816
+ }
817
+ async function optionalUploadAuth(req, res, next) {
818
+ const auth = await optionalAuth(req);
819
+ // A stale or malformed credential must not turn an intended owned upload
820
+ // into an anonymous public upload on compatibility deployments.
821
+ if (!auth && (req.get("authorization") !== undefined || !config.allowAnonymousUploads)) {
822
+ return res.status(401).json({ ok: false, error: "An API key is required for uploads." });
823
+ }
824
+ req.auth = auth || publicUploadAuth;
825
+ next();
826
+ }
827
+ async function optionalAuth(req) {
828
+ const header = req.get("authorization") || "";
829
+ const match = header.match(/^Bearer\s+(.+)$/i);
830
+ if (!match)
831
+ return null;
832
+ return findApiKeyByToken(match[1].trim());
833
+ }
834
+ const noStoreHeaders = (_req, res, next) => {
835
+ res.setHeader("X-Openplan-Request-Id", newInternalId());
836
+ res.setHeader("Referrer-Policy", "no-referrer");
837
+ res.setHeader("X-Content-Type-Options", "nosniff");
838
+ res.setHeader("Cache-Control", "no-store");
839
+ res.setHeader("Content-Security-Policy", APPLICATION_CSP);
840
+ next();
841
+ };
842
+ function getHomeUrlForRequest(req) {
843
+ return getHomeUrl({
844
+ publicBaseUrl: config.publicBaseUrl,
845
+ requestBaseUrl: getRequestBaseUrl(req)
846
+ });
847
+ }
848
+ function getDraftIdFromRequest(req) {
849
+ return getDraftIdFromHost({
850
+ publicBaseUrl: config.publicBaseUrl,
851
+ host: req.hostname || req.get("host")
852
+ });
853
+ }
854
+ function cleanText(value, maxLength = 255) {
855
+ if (typeof value !== "string")
856
+ return null;
857
+ const trimmed = value.trim();
858
+ return trimmed ? trimmed.slice(0, maxLength) : null;
859
+ }
860
+ function routeParam(req, name) {
861
+ const value = req.params[name];
862
+ return Array.isArray(value) ? value[0] ?? "" : value ?? "";
863
+ }
864
+ function errorMessage(error) {
865
+ return error instanceof Error ? error.message : String(error);
866
+ }
867
+ //# sourceMappingURL=api.js.map