@jant/core 0.7.0 → 0.7.1

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 (158) hide show
  1. package/bin/commands/export.js +3 -1
  2. package/bin/commands/import-site.js +689 -216
  3. package/bin/commands/setup.js +136 -0
  4. package/bin/commands/site/export.js +71 -34
  5. package/bin/commands/site/pull-media.js +2 -6
  6. package/bin/commands/site/snapshot/export.js +18 -60
  7. package/bin/commands/site/snapshot/import.js +22 -23
  8. package/bin/lib/d1-query.js +87 -2
  9. package/bin/lib/hugo-markdown.js +4 -0
  10. package/bin/lib/site-pull-media.js +21 -28
  11. package/bin/lib/site-selection.js +10 -1
  12. package/bin/lib/site-snapshot.js +338 -3
  13. package/bin/lib/sql-export.js +68 -5
  14. package/bin/lib/wrangler-cli.js +9 -0
  15. package/bin/lib/zip-archive.js +187 -0
  16. package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
  17. package/dist/client/.vite/manifest.json +20 -20
  18. package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
  19. package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
  20. package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
  21. package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
  22. package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
  23. package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
  24. package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
  25. package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
  26. package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
  27. package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
  28. package/dist/index.js +2 -2
  29. package/dist/node.js +9 -5
  30. package/package.json +13 -3
  31. package/src/__tests__/dev-scripts.test.ts +203 -0
  32. package/src/__tests__/export-collection-order.test.ts +276 -0
  33. package/src/__tests__/export-feed-ids.test.ts +184 -0
  34. package/src/__tests__/export-feed-order.test.ts +294 -0
  35. package/src/__tests__/export-hugo-build.test.ts +130 -0
  36. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  37. package/src/__tests__/export-service.test.ts +611 -28
  38. package/src/__tests__/export-smart-collection.test.ts +329 -0
  39. package/src/__tests__/helpers/hugo-site.ts +146 -0
  40. package/src/__tests__/import-site-command.test.ts +487 -1
  41. package/src/__tests__/mise-config.test.ts +75 -4
  42. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  43. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  44. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  45. package/src/__tests__/snapshot-settings.test.ts +97 -0
  46. package/src/__tests__/snapshot-tables.test.ts +219 -0
  47. package/src/__tests__/sql-export.test.ts +173 -0
  48. package/src/__tests__/zip-archive.test.ts +106 -0
  49. package/src/app.tsx +15 -6
  50. package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
  51. package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
  52. package/src/client/components/jant-repo-picker-types.ts +6 -1
  53. package/src/client/components/jant-repo-picker.ts +4 -7
  54. package/src/client/components/jant-settings-general.ts +17 -12
  55. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  56. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  57. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  58. package/src/client/tiptap/extensions.ts +0 -3
  59. package/src/client/tiptap/structural-keymap.ts +158 -47
  60. package/src/db/__tests__/d1-query.test.ts +56 -1
  61. package/src/i18n/locales/settings/en.po +8 -8
  62. package/src/i18n/locales/settings/en.ts +1 -1
  63. package/src/i18n/locales/settings/zh-Hans.po +8 -8
  64. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  65. package/src/i18n/locales/settings/zh-Hant.po +8 -8
  66. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  67. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  68. package/src/lib/__tests__/image.test.ts +27 -1
  69. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  70. package/src/lib/__tests__/markdown.test.ts +10 -0
  71. package/src/lib/__tests__/resolve-config.test.ts +67 -0
  72. package/src/lib/__tests__/schemas.test.ts +27 -1
  73. package/src/lib/__tests__/timeline.test.ts +87 -0
  74. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  75. package/src/lib/discover.ts +3 -1
  76. package/src/lib/github-sync-repo-name.ts +45 -0
  77. package/src/lib/hugo-markdown.ts +46 -0
  78. package/src/lib/image.ts +17 -4
  79. package/src/lib/markdown-manager.ts +392 -2
  80. package/src/lib/post-body-html.ts +11 -4
  81. package/src/lib/resolve-config.ts +58 -1
  82. package/src/lib/schemas.ts +50 -5
  83. package/src/lib/thread-fold.ts +3 -3
  84. package/src/lib/timeline.ts +1 -1
  85. package/src/lib/tiptap-to-markdown.ts +13 -7
  86. package/src/lib/url.ts +20 -0
  87. package/src/lib/view.ts +2 -2
  88. package/src/node/__tests__/cli-setup.test.ts +163 -0
  89. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  90. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  91. package/src/node/__tests__/runtime.test.ts +38 -0
  92. package/src/node/index.ts +2 -0
  93. package/src/node/request-handler.ts +3 -1
  94. package/src/routes/api/__tests__/posts.test.ts +24 -0
  95. package/src/routes/api/__tests__/upload.test.ts +34 -0
  96. package/src/routes/api/export.ts +3 -3
  97. package/src/routes/api/internal/sites.ts +0 -1
  98. package/src/routes/api/posts.ts +2 -0
  99. package/src/routes/api/public/posts.ts +21 -1
  100. package/src/routes/api/upload.ts +10 -3
  101. package/src/routes/compose.tsx +4 -0
  102. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  103. package/src/routes/dash/settings.tsx +212 -70
  104. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  105. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  106. package/src/routes/pages/archive.tsx +17 -11
  107. package/src/routes/pages/featured.tsx +11 -5
  108. package/src/routes/pages/page.tsx +53 -31
  109. package/src/routes/pages/search.tsx +4 -2
  110. package/src/runtime/__tests__/readiness.test.ts +23 -0
  111. package/src/runtime/node.ts +49 -0
  112. package/src/runtime/readiness.ts +15 -0
  113. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  114. package/src/services/__tests__/custom-url.test.ts +34 -0
  115. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  116. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  117. package/src/services/__tests__/media.test.ts +31 -0
  118. package/src/services/__tests__/path.test.ts +56 -0
  119. package/src/services/__tests__/post-timeline.test.ts +127 -0
  120. package/src/services/__tests__/post.test.ts +74 -0
  121. package/src/services/bootstrap.ts +263 -44
  122. package/src/services/custom-url.ts +2 -2
  123. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  124. package/src/services/export-theme/layouts/_default/list.html +2 -63
  125. package/src/services/export-theme/layouts/_default/rss.xml +27 -38
  126. package/src/services/export-theme/layouts/collections/list.html +3 -3
  127. package/src/services/export-theme/layouts/featured/list.html +1 -4
  128. package/src/services/export-theme/layouts/index.html +40 -26
  129. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  130. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  131. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  132. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  133. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  134. package/src/services/export-theme/layouts/partials/head.html +1 -1
  135. package/src/services/export-theme/layouts/partials/header.html +5 -3
  136. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  137. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  138. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  139. package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
  140. package/src/services/export-theme/layouts/post/list.html +1 -1
  141. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  142. package/src/services/export-theme/styles/main.css +0 -1
  143. package/src/services/export-theme/theme.toml +1 -1
  144. package/src/services/export.ts +620 -71
  145. package/src/services/github-app-installations.ts +171 -4
  146. package/src/services/github-sync.ts +69 -15
  147. package/src/services/mcp.ts +19 -1
  148. package/src/services/media.ts +8 -1
  149. package/src/services/path.ts +34 -1
  150. package/src/services/post.ts +214 -39
  151. package/src/services/site-admin.ts +3 -7
  152. package/src/services/site.ts +83 -31
  153. package/src/styles/ui.css +7 -1
  154. package/src/types/app-context.ts +16 -0
  155. package/src/types/bindings.ts +5 -0
  156. package/src/types/operations.ts +10 -0
  157. package/src/ui/dash/settings/GeneralContent.tsx +10 -8
  158. package/src/client/tiptap/exitable-marks.ts +0 -73
@@ -95,6 +95,7 @@ import {
95
95
  getInstallation,
96
96
  listInstallationReposPage,
97
97
  searchInstallationRepos,
98
+ type InstallationAccount,
98
99
  } from "../../lib/github-app.js";
99
100
  import {
100
101
  isSyncPending,
@@ -103,6 +104,7 @@ import {
103
104
  triggerGitHubSyncInline,
104
105
  } from "../../lib/github-sync-trigger.js";
105
106
  import { buildSyncSiteConfig } from "../../lib/github-sync-site-config.js";
107
+ import { suggestSyncRepoName } from "../../lib/github-sync-repo-name.js";
106
108
  import { buildConfigEditorFields } from "../../lib/api-settings.js";
107
109
  import {
108
110
  readGitHubSyncStatus,
@@ -182,6 +184,23 @@ function requireSessionId(c: Context<Env>): string {
182
184
  return sessionId;
183
185
  }
184
186
 
187
+ /**
188
+ * The user behind the current request.
189
+ *
190
+ * Same contract as `requireSessionId`: `requireAuth` has already proven the
191
+ * session belongs to a member of this site, so an absent user is a wiring
192
+ * mistake. Used wherever a decision is scoped to the person rather than to
193
+ * the site — the GitHub App installations they may reuse, most of all.
194
+ */
195
+ function requireSessionUserId(c: Context<Env>): string {
196
+ const userId = c.var.session?.user?.id;
197
+ if (!userId) {
198
+ throw new UnauthorizedError();
199
+ }
200
+
201
+ return userId;
202
+ }
203
+
185
204
  /**
186
205
  * Breadcrumb labels for admin settings pages.
187
206
  *
@@ -2080,6 +2099,49 @@ settingsRoutes.post("/github-sync/disconnect", async (c) => {
2080
2099
  // GitHub App install flow
2081
2100
  // ---------------------------------------------------------------------------
2082
2101
 
2102
+ /**
2103
+ * Recreate installation bindings for sites that sync through the App
2104
+ * but have no row to prove it.
2105
+ *
2106
+ * GitHub is the only source for the account behind an installation id,
2107
+ * so the repair costs one call per distinct id — bounded by how many
2108
+ * sites the author has, and only ever reached when they would otherwise
2109
+ * be shown a GitHub page that cannot lead anywhere. Ids GitHub no longer
2110
+ * recognizes are skipped: an uninstalled App has nothing to rebuild.
2111
+ */
2112
+ async function rebuildInstallationBindings(
2113
+ c: Context<Env>,
2114
+ app: import("../../lib/env.js").GitHubAppEnvConfig,
2115
+ userId: string,
2116
+ ): Promise<void> {
2117
+ const syncing =
2118
+ await c.var.services.githubAppInstallations.listSyncingInstallationsForUser(
2119
+ userId,
2120
+ );
2121
+ if (syncing.length === 0) return;
2122
+
2123
+ const accounts = new Map<string, InstallationAccount>();
2124
+ for (const { installationId } of syncing) {
2125
+ if (accounts.has(installationId)) continue;
2126
+ try {
2127
+ const installation = await getInstallation(app, installationId);
2128
+ accounts.set(installationId, installation.account);
2129
+ } catch {
2130
+ continue;
2131
+ }
2132
+ }
2133
+
2134
+ for (const { siteId, installationId } of syncing) {
2135
+ const account = accounts.get(installationId);
2136
+ if (!account) continue;
2137
+ await c.var.services.githubAppInstallations.upsertInstallation(
2138
+ installationId,
2139
+ siteId,
2140
+ account,
2141
+ );
2142
+ }
2143
+ }
2144
+
2083
2145
  /**
2084
2146
  * Redirect the user to GitHub to install the App on their account/org.
2085
2147
  *
@@ -2092,26 +2154,46 @@ settingsRoutes.get("/github-sync/app/install", async (c) => {
2092
2154
  return c.text("GitHub App is not configured on this deployment.", 404);
2093
2155
  }
2094
2156
 
2095
- // Reinstall-same-account predicate (§5): when this site already has at
2096
- // least one authorized installation, skip the GitHub round-trip and
2097
- // render the picker directly. The GitHub redirect is a dead end when
2098
- // the App is already installed on the chosen account — GitHub shows
2099
- // its Configure page and never comes back with installation_id.
2157
+ // Reinstall-same-account predicate: when the signed-in author has
2158
+ // already authorized an installation — on this site or on any other
2159
+ // site they belong to — skip the GitHub round-trip and render the
2160
+ // picker directly. The GitHub redirect is a dead end once the App is
2161
+ // installed on the chosen account: GitHub shows its Configure page
2162
+ // and never comes back with installation_id.
2163
+ //
2164
+ // The scope is the user's sites rather than this one because a GitHub
2165
+ // App installs once per GitHub account. An author connecting their
2166
+ // second blog to the same account has no binding here yet and cannot
2167
+ // create one through GitHub, so site-scoping this check would leave
2168
+ // them with no way in at all.
2100
2169
  //
2101
2170
  // `?force=new` bypasses the predicate so the picker's "Install on
2102
2171
  // another account" action can still reach GitHub to add a fresh
2103
2172
  // install.
2104
2173
  const forceNew = c.req.query("force") === "new";
2105
2174
  if (!forceNew) {
2106
- const existing =
2107
- await c.var.services.githubAppInstallations.listInstallationsForSite(
2108
- c.var.currentSite.id,
2175
+ const userId = requireSessionUserId(c);
2176
+ let existing =
2177
+ await c.var.services.githubAppInstallations.listInstallationsForUser(
2178
+ userId,
2109
2179
  );
2180
+ // An author with nothing to show but a site already syncing through
2181
+ // the App is in the one state the redirect cannot rescue: GitHub
2182
+ // will not install the App a second time on that account. Rebuild
2183
+ // the binding from the sync settings instead of sending them into
2184
+ // the dead end.
2185
+ if (existing.length === 0) {
2186
+ await rebuildInstallationBindings(c, app, userId);
2187
+ existing =
2188
+ await c.var.services.githubAppInstallations.listInstallationsForUser(
2189
+ userId,
2190
+ );
2191
+ }
2110
2192
  if (existing.length > 0) {
2111
2193
  const navData = await getNavigationData(c);
2112
2194
  const base = publicPath(c, "/settings/github-sync");
2113
- const labels = buildRepoPickerLabels(c);
2114
- const suggestedRepoName = buildSuggestedRepoName(c);
2195
+ const suggestedRepoName = suggestSyncRepoName(c.var.appConfig.siteUrl);
2196
+ const labels = buildRepoPickerLabels(c, suggestedRepoName);
2115
2197
  return renderPublicPage(c, {
2116
2198
  title: buildPageTitle(
2117
2199
  "GitHub Sync — Pick Repository",
@@ -2209,19 +2291,17 @@ settingsRoutes.get("/github-sync/app/callback", async (c) => {
2209
2291
  }
2210
2292
  }
2211
2293
 
2212
- // One-shot: clear the cookie so it can't be replayed.
2213
- setCookie(c, "jant_gh_app_state", "", {
2214
- httpOnly: true,
2215
- sameSite: "Lax",
2216
- path: "/",
2217
- maxAge: 0,
2218
- });
2219
-
2220
2294
  // Fetch the account info for this installation and remember it. The
2221
- // picker's owner dropdown reads the stored list, so subsequent visits
2222
- // can switch between accounts without re-running the install flow.
2223
- // Failures are non-fatal: this callback must still render the picker
2224
- // so the user isn't locked out right after a successful install.
2295
+ // binding is what every later step reads: the picker's account
2296
+ // dropdown, the endpoints that authorize an installation id, and the
2297
+ // App webhook's fan-out. Rendering the picker without it would show an
2298
+ // empty account list and nothing to pick.
2299
+ //
2300
+ // So a failure here has to stay recoverable, and the only thing that
2301
+ // makes this page replayable is the state cookie — GitHub will not
2302
+ // hand out a second callback for an App that is now installed. Hence
2303
+ // the clearing below sits *after* the binding is written: until then,
2304
+ // reloading this URL is the whole retry.
2225
2305
  try {
2226
2306
  const installation = await getInstallation(app, installationId);
2227
2307
  await c.var.services.githubAppInstallations.upsertInstallation(
@@ -2230,14 +2310,24 @@ settingsRoutes.get("/github-sync/app/callback", async (c) => {
2230
2310
  installation.account,
2231
2311
  );
2232
2312
  } catch {
2233
- // Swallow — the legacy inline picker still works with just the
2234
- // installation_id from the URL, just without the owner list.
2313
+ return c.text(
2314
+ "The App was installed, but GitHub did not answer with the account details. Reload this page to try again.",
2315
+ 502,
2316
+ );
2235
2317
  }
2236
2318
 
2319
+ // One-shot: clear the cookie so it can't be replayed.
2320
+ setCookie(c, "jant_gh_app_state", "", {
2321
+ httpOnly: true,
2322
+ sameSite: "Lax",
2323
+ path: "/",
2324
+ maxAge: 0,
2325
+ });
2326
+
2237
2327
  const navData = await getNavigationData(c);
2238
2328
  const base = publicPath(c, "/settings/github-sync");
2239
- const labels = buildRepoPickerLabels(c);
2240
- const suggestedRepoName = buildSuggestedRepoName(c);
2329
+ const suggestedRepoName = suggestSyncRepoName(c.var.appConfig.siteUrl);
2330
+ const labels = buildRepoPickerLabels(c, suggestedRepoName);
2241
2331
 
2242
2332
  return renderPublicPage(c, {
2243
2333
  title: buildPageTitle("GitHub Sync — Pick Repository", navData.siteName),
@@ -2253,7 +2343,7 @@ settingsRoutes.get("/github-sync/app/callback", async (c) => {
2253
2343
  labels={labels}
2254
2344
  api-base={`${base}/app`}
2255
2345
  connect-url={`${base}/app/connect`}
2256
- install-url={`${base}/app/install`}
2346
+ install-url={`${base}/app/install?force=new`}
2257
2347
  cancel-url={publicPath(c, "/settings")}
2258
2348
  create-repo-name-hint={suggestedRepoName}
2259
2349
  >
@@ -2271,29 +2361,36 @@ settingsRoutes.get("/github-sync/app/callback", async (c) => {
2271
2361
  });
2272
2362
 
2273
2363
  /**
2274
- * Derive a default repository name to prefill on github.com/new.
2364
+ * Values that keep a placeholder literal for the client to fill in.
2365
+ *
2366
+ * `i18n._()` renders a placeholder it has no value for as an empty string, so
2367
+ * a label whose value only exists in the browser — a repo count, the picked
2368
+ * repo — must be formatted with the placeholder's own token as its value.
2369
+ * `{repo}` then survives into the serialized labels and the component's
2370
+ * `.replace("{repo}", …)` finds it.
2275
2371
  *
2276
- * Uses the site's host — the first DNS label is a stable, URL-safe
2277
- * identifier tied to this specific Jant instance. Fallback to
2278
- * "jant-site-sync" when the host parse fails so we never hand GitHub an
2279
- * empty `name=`. The `-jant-sync` suffix disambiguates the sync mirror
2280
- * from a user's own `{slug}-jant` source repo.
2372
+ * @param names - Placeholder names the client interpolates.
2373
+ * @returns A values object mapping each name to its own `{name}` token.
2374
+ * @example
2375
+ * i18n._(msg({ message: "Type {repo} to confirm" }), keepForClient("repo"));
2376
+ * // "Type {repo} to confirm"
2281
2377
  */
2282
- function buildSuggestedRepoName(c: Context<Env>): string {
2283
- let firstLabel = "";
2284
- try {
2285
- firstLabel = new URL(c.var.appConfig.siteUrl).host.split(".")[0] ?? "";
2286
- } catch {
2287
- /* fall through */
2288
- }
2289
- const slug = firstLabel
2290
- .toLowerCase()
2291
- .replace(/[^a-z0-9-_]+/g, "-")
2292
- .replace(/^-+|-+$/g, "");
2293
- return slug ? `${slug}-jant-sync` : "jant-site-sync";
2378
+ function keepForClient(...names: string[]): Record<string, string> {
2379
+ return Object.fromEntries(names.map((name) => [name, `{${name}}`]));
2294
2380
  }
2295
2381
 
2296
- function buildRepoPickerLabels(c: Context<Env>): string {
2382
+ /**
2383
+ * Serialize the picker's strings for the client component.
2384
+ *
2385
+ * @param c - Request context, for the active locale.
2386
+ * @param suggestedRepoName - Repository name prefilled on github.com/new;
2387
+ * interpolated here because the client cannot format ICU messages.
2388
+ * @returns The labels as a JSON string for the `labels` attribute.
2389
+ */
2390
+ function buildRepoPickerLabels(
2391
+ c: Context<Env>,
2392
+ suggestedRepoName: string,
2393
+ ): string {
2297
2394
  const i18n = getI18n(c);
2298
2395
  return JSON.stringify({
2299
2396
  pageTitle: i18n._(
@@ -2380,6 +2477,7 @@ function buildRepoPickerLabels(c: Context<Env>): string {
2380
2477
  comment:
2381
2478
  "@context: GitHub sync picker — paginated repo count hint. Placeholders {shown} and {total} are integers.",
2382
2479
  }),
2480
+ keepForClient("shown", "total"),
2383
2481
  ),
2384
2482
  repoSearchHint: i18n._(
2385
2483
  msg({
@@ -2404,10 +2502,12 @@ function buildRepoPickerLabels(c: Context<Env>): string {
2404
2502
  ),
2405
2503
  createOnGitHubHint: i18n._(
2406
2504
  msg({
2407
- message: "We'll prefill the name {name}. The list refreshes on return.",
2505
+ message:
2506
+ "Name prefilled as {name}. The list refreshes when you return.",
2408
2507
  comment:
2409
2508
  "@context: GitHub sync picker — hint under the create-on-github entry. Placeholder {name} is the suggested repo name.",
2410
2509
  }),
2510
+ { name: suggestedRepoName },
2411
2511
  ),
2412
2512
  classifyLoading: i18n._(
2413
2513
  msg({
@@ -2437,6 +2537,7 @@ function buildRepoPickerLabels(c: Context<Env>): string {
2437
2537
  comment:
2438
2538
  "@context: GitHub sync picker — blocking message when marker belongs to another site. Placeholder {host} is the other site's host.",
2439
2539
  }),
2540
+ keepForClient("host"),
2440
2541
  ),
2441
2542
  classificationForeign: i18n._(
2442
2543
  msg({
@@ -2459,6 +2560,7 @@ function buildRepoPickerLabels(c: Context<Env>): string {
2459
2560
  comment:
2460
2561
  "@context: GitHub sync picker — explanatory body for foreign-repo confirmation. Placeholder {repo} is the owner/repo slug.",
2461
2562
  }),
2563
+ keepForClient("repo"),
2462
2564
  ),
2463
2565
  confirmInputLabel: i18n._(
2464
2566
  msg({
@@ -2466,6 +2568,7 @@ function buildRepoPickerLabels(c: Context<Env>): string {
2466
2568
  comment:
2467
2569
  "@context: GitHub sync picker — label above the typed-confirmation input",
2468
2570
  }),
2571
+ keepForClient("repo"),
2469
2572
  ),
2470
2573
  confirmInputPlaceholder: i18n._(
2471
2574
  msg({
@@ -2544,6 +2647,16 @@ settingsRoutes.post("/github-sync/app/connect", async (c) => {
2544
2647
  : c.text("Missing installationId or repo.", 400);
2545
2648
  }
2546
2649
 
2650
+ // The installation has to be one the author already authorized, here
2651
+ // or on another of their sites. The account snapshot also spares us a
2652
+ // GitHub round-trip when recording the binding further down.
2653
+ const installation = await findVisibleInstallation(c, installationId);
2654
+ if (!installation) {
2655
+ return wantsJson
2656
+ ? c.json({ error: "Unknown installation." }, 404)
2657
+ : c.text("Unknown installation.", 404);
2658
+ }
2659
+
2547
2660
  const { parseRepoSlug, createGitHubClient } =
2548
2661
  await import("../../lib/github-api.js");
2549
2662
  const parsed = parseRepoSlug(repo);
@@ -2604,6 +2717,18 @@ settingsRoutes.post("/github-sync/app/connect", async (c) => {
2604
2717
  : c.text(msg, 409);
2605
2718
  }
2606
2719
 
2720
+ // Bind the installation to this site. The callback does this too, but
2721
+ // only for the site that ran the install; connecting a second site to
2722
+ // an installation the author already has is exactly the case that
2723
+ // never passes through there. Without the row, the App-level webhook's
2724
+ // fan-out (uninstall, suspend, repository removed) skips this site
2725
+ // while it happily keeps syncing.
2726
+ await c.var.services.githubAppInstallations.upsertInstallation(
2727
+ installationId,
2728
+ c.var.currentSite.id,
2729
+ installation.account,
2730
+ );
2731
+
2607
2732
  // Persist config before creating webhook so the sync service can load it.
2608
2733
  await c.var.services.settings.set("GITHUB_SYNC_AUTH_MODE", "app");
2609
2734
  await c.var.services.settings.set(
@@ -2688,21 +2813,38 @@ async function getInstallationTokenFromApp(
2688
2813
  return getInstallationToken(app, installationId);
2689
2814
  }
2690
2815
 
2691
- /** List GitHub App installations authorized for this site. */
2816
+ /**
2817
+ * List the GitHub App installations the signed-in author may connect
2818
+ * this site to: everything authorized on any site they belong to, this
2819
+ * one included.
2820
+ */
2692
2821
  settingsRoutes.get("/github-sync/app/installations", async (c) => {
2693
2822
  const installations =
2694
- await c.var.services.githubAppInstallations.listInstallationsForSite(
2695
- c.var.currentSite.id,
2823
+ await c.var.services.githubAppInstallations.listInstallationsForUser(
2824
+ requireSessionUserId(c),
2696
2825
  );
2697
- return c.json({
2698
- installations: installations.map((entry) => ({
2699
- installationId: entry.installationId,
2700
- account: entry.account,
2701
- addedAt: entry.addedAt,
2702
- })),
2703
- });
2826
+ return c.json({ installations });
2704
2827
  });
2705
2828
 
2829
+ /**
2830
+ * Resolve an `installationId` that arrived in a request into an
2831
+ * installation the caller is allowed to act on.
2832
+ *
2833
+ * Every endpoint below takes the id from the client, and an installation
2834
+ * id is a small integer — without this check a signed-in author on one
2835
+ * site could name another tenant's installation and read their private
2836
+ * repository names, or push this site's content into one of their repos.
2837
+ */
2838
+ async function findVisibleInstallation(
2839
+ c: Context<Env>,
2840
+ installationId: string,
2841
+ ) {
2842
+ return c.var.services.githubAppInstallations.findInstallationForUser(
2843
+ installationId,
2844
+ requireSessionUserId(c),
2845
+ );
2846
+ }
2847
+
2706
2848
  /**
2707
2849
  * List (or search) repositories accessible via an installation.
2708
2850
  *
@@ -2727,18 +2869,13 @@ settingsRoutes.get("/github-sync/app/repos", async (c) => {
2727
2869
  const page =
2728
2870
  Number.isFinite(pageParam) && pageParam > 0 ? Math.floor(pageParam) : 1;
2729
2871
 
2872
+ const installation = await findVisibleInstallation(c, installationId);
2873
+ if (!installation) {
2874
+ return c.json({ error: "Unknown installation." }, 404);
2875
+ }
2876
+
2730
2877
  try {
2731
2878
  if (q) {
2732
- const installations =
2733
- await c.var.services.githubAppInstallations.listInstallationsForSite(
2734
- c.var.currentSite.id,
2735
- );
2736
- const installation = installations.find(
2737
- (i) => i.installationId === installationId,
2738
- );
2739
- if (!installation) {
2740
- return c.json({ error: "Unknown installation." }, 404);
2741
- }
2742
2879
  const result = await searchInstallationRepos(
2743
2880
  app,
2744
2881
  installationId,
@@ -2765,11 +2902,13 @@ settingsRoutes.get("/github-sync/app/repos", async (c) => {
2765
2902
  } catch (err) {
2766
2903
  const detail = err instanceof Error ? err.message : String(err);
2767
2904
  // GitHub returns 401 when an installation was uninstalled on their
2768
- // side. Clean up our cached entry so the UI stops showing a dead owner.
2905
+ // side. Clean up our cached entries so the UI stops showing a dead
2906
+ // owner — across the user's sites, since the binding that surfaced
2907
+ // this account may well belong to another one of them.
2769
2908
  if (/\b401\b/.test(detail) || /\b404\b/.test(detail)) {
2770
- await c.var.services.githubAppInstallations.removeInstallation(
2909
+ await c.var.services.githubAppInstallations.removeInstallationForUser(
2771
2910
  installationId,
2772
- c.var.currentSite.id,
2911
+ requireSessionUserId(c),
2773
2912
  );
2774
2913
  return c.json(
2775
2914
  { error: "Installation is no longer accessible.", removed: true },
@@ -2799,6 +2938,9 @@ settingsRoutes.post("/github-sync/app/classify", async (c) => {
2799
2938
  if (!installationId || !repo) {
2800
2939
  return c.json({ error: "Missing installationId or repo." }, 400);
2801
2940
  }
2941
+ if (!(await findVisibleInstallation(c, installationId))) {
2942
+ return c.json({ error: "Unknown installation." }, 404);
2943
+ }
2802
2944
 
2803
2945
  const { parseRepoSlug, createGitHubClient } =
2804
2946
  await import("../../lib/github-api.js");
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Round-trip budget for the post page.
3
+ *
4
+ * On Workers a public page's cost is almost entirely the number of sequential
5
+ * D1 reads it makes — the same render against local SQLite is about a
6
+ * millisecond. Serving `/{slug}` used to read `path_registry` twice for one
7
+ * address and the `post` table four times for one post, and nothing failed
8
+ * when it did. These tests are that missing failure.
9
+ */
10
+
11
+ import { describe, expect, it, vi } from "vitest";
12
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
13
+ import { withConfig } from "../../../middleware/config.js";
14
+ import { i18nMiddleware } from "../../../i18n/index.js";
15
+ import { normalizePath, toPublicHref } from "../../../lib/url.js";
16
+ import { pageRoutes } from "../page.js";
17
+
18
+ /**
19
+ * Mount the catch-all behind the middleware `createApp()` puts in front of it.
20
+ *
21
+ * The stored-redirect middleware is reproduced rather than imported because it
22
+ * is defined inline in `app.tsx`; what is under test is that it hands its
23
+ * lookup on and the route takes it, so the stash is the part that must match.
24
+ */
25
+ function createPostPageApp() {
26
+ const testApp = createTestApp();
27
+ const { app } = testApp;
28
+
29
+ app.use("*", async (c, next) => {
30
+ c.set("publicPath", c.req.path);
31
+ c.set("publicRequestUrl", c.req.url);
32
+ await next();
33
+ });
34
+
35
+ app.use("*", async (c, next) => {
36
+ const storedPath = normalizePath(new URL(c.req.url).pathname);
37
+ const record = await c.var.services.paths.resolve(storedPath);
38
+ c.set("pathLookup", { path: storedPath, record });
39
+ if (record?.kind === "redirect" && record.redirectToPath) {
40
+ return c.redirect(
41
+ toPublicHref(`/${record.redirectToPath}`),
42
+ record.redirectType ?? 301,
43
+ );
44
+ }
45
+ await next();
46
+ });
47
+ app.use("*", withConfig());
48
+ app.use("*", i18nMiddleware());
49
+ app.route("/", pageRoutes);
50
+
51
+ return testApp;
52
+ }
53
+
54
+ /** Every SQL statement prepared while `run` executes. */
55
+ async function captureStatements<T>(
56
+ sqlite: ReturnType<typeof createTestApp>["sqlite"],
57
+ run: () => Promise<T>,
58
+ ): Promise<[T, string[]]> {
59
+ const original = sqlite.prepare.bind(sqlite);
60
+ const statements: string[] = [];
61
+ const spy = vi.spyOn(sqlite, "prepare").mockImplementation(((
62
+ source: string,
63
+ ) => {
64
+ statements.push(source.replace(/\s+/g, " "));
65
+ return original(source);
66
+ }) as typeof sqlite.prepare);
67
+ try {
68
+ return [await run(), statements];
69
+ } finally {
70
+ spy.mockRestore();
71
+ }
72
+ }
73
+
74
+ /** Reads that resolve one address, i.e. filter `path_registry` by `path`. */
75
+ function countAddressLookups(statements: string[]): number {
76
+ return statements.filter(
77
+ (sql) =>
78
+ /from "path_registry"/i.test(sql) &&
79
+ /"path_registry"\."path" = \?/i.test(sql),
80
+ ).length;
81
+ }
82
+
83
+ /** Reads that scan the `post` table. */
84
+ function countPostReads(statements: string[]): number {
85
+ return statements.filter((sql) => /from "post"/i.test(sql)).length;
86
+ }
87
+
88
+ describe("post page round trips", () => {
89
+ it("resolves the requested address once, and keeps its post reads to three", async () => {
90
+ const { app, services, sqlite } = createPostPageApp();
91
+ const post = await services.posts.create({
92
+ format: "note",
93
+ title: "Budget",
94
+ bodyMarkdown: "Body",
95
+ status: "published",
96
+ });
97
+
98
+ const [res, statements] = await captureStatements(sqlite, () =>
99
+ app.request(`/${post.slug}`),
100
+ );
101
+ expect(res.status).toBe(200);
102
+
103
+ // The middleware's lookup is the route's lookup.
104
+ expect(countAddressLookups(statements)).toBe(1);
105
+
106
+ // Three reads, and each is for something different: the post with its
107
+ // canonical alias, its thread, and the thread ids that collection
108
+ // membership is keyed by. A root post's visibility rides along in its own
109
+ // row, so nothing goes back for that.
110
+ expect(countPostReads(statements)).toBe(3);
111
+ });
112
+
113
+ it("redirects a slug to its custom URL without a separate alias lookup", async () => {
114
+ const { app, services, sqlite } = createPostPageApp();
115
+ const post = await services.posts.create({
116
+ format: "note",
117
+ title: "Renamed",
118
+ bodyMarkdown: "Body",
119
+ status: "published",
120
+ });
121
+ await services.customUrls.create({
122
+ path: "renamed-url",
123
+ targetType: "post",
124
+ targetId: post.id,
125
+ });
126
+
127
+ const [res, statements] = await captureStatements(sqlite, () =>
128
+ app.request(`/${post.slug}`),
129
+ );
130
+ expect(res.status).toBe(301);
131
+ expect(res.headers.get("location")).toBe("/renamed-url");
132
+
133
+ // The address lookup, and nothing more: the alias came back with the post.
134
+ expect(countAddressLookups(statements)).toBe(1);
135
+ expect(countPostReads(statements)).toBe(1);
136
+ });
137
+
138
+ it("takes the newest alias as the canonical address", async () => {
139
+ const { app, services } = createPostPageApp();
140
+ const post = await services.posts.create({
141
+ format: "note",
142
+ title: "Renamed twice",
143
+ bodyMarkdown: "Body",
144
+ status: "published",
145
+ });
146
+ await services.customUrls.create({
147
+ path: "first-name",
148
+ targetType: "post",
149
+ targetId: post.id,
150
+ });
151
+ await services.customUrls.create({
152
+ path: "second-name",
153
+ targetType: "post",
154
+ targetId: post.id,
155
+ });
156
+
157
+ const res = await app.request(`/${post.slug}`);
158
+ expect(res.status).toBe(301);
159
+ expect(res.headers.get("location")).toBe("/second-name");
160
+ });
161
+ });
162
+
163
+ describe("post list round trips", () => {
164
+ it("reads thread-root visibility from the rows it already loaded", async () => {
165
+ const { services, sqlite } = createTestApp();
166
+ for (let i = 0; i < 3; i++) {
167
+ await services.posts.create({
168
+ format: "note",
169
+ title: `Post ${i}`,
170
+ bodyMarkdown: "Body",
171
+ status: "published",
172
+ });
173
+ }
174
+
175
+ const [listed, statements] = await captureStatements(sqlite, () =>
176
+ services.posts.list({ status: "published", excludeReplies: true }),
177
+ );
178
+
179
+ expect(listed).toHaveLength(3);
180
+ // A list of thread roots carries its own visibility, so the only `post`
181
+ // read is the list itself.
182
+ expect(countPostReads(statements)).toBe(1);
183
+ });
184
+
185
+ it("still looks up the root for a reply loaded on its own", async () => {
186
+ const { services, sqlite } = createTestApp();
187
+ const root = await services.posts.create({
188
+ format: "note",
189
+ title: "Root",
190
+ bodyMarkdown: "Body",
191
+ status: "published",
192
+ visibility: "private",
193
+ });
194
+ const reply = await services.posts.create({
195
+ format: "note",
196
+ bodyMarkdown: "Reply",
197
+ replyToId: root.id,
198
+ status: "published",
199
+ });
200
+
201
+ const [loaded, statements] = await captureStatements(sqlite, () =>
202
+ services.posts.getById(reply.id),
203
+ );
204
+
205
+ // A reply's own visibility column is null — it inherits the root's — and
206
+ // the root is not among the rows loaded here, so the second read is what
207
+ // makes the reply private too.
208
+ expect(countPostReads(statements)).toBe(2);
209
+ expect(loaded?.visibility).toBe("private");
210
+ });
211
+ });