@jant/core 0.3.42 → 0.3.44

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 (99) hide show
  1. package/bin/commands/import-site.js +1 -1
  2. package/bin/commands/search-reindex.js +175 -0
  3. package/bin/lib/hugo-markdown.js +102 -0
  4. package/bin/lib/site-pull-media.js +1 -4
  5. package/dist/app-BI9bnCkO.js +5 -0
  6. package/dist/{app-Cu3lveYI.js → app-CtJDxZBb.js} +969 -373
  7. package/dist/client/.vite/manifest.json +2 -2
  8. package/dist/client/_assets/client-BQH7AQ24.css +2 -0
  9. package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
  10. package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
  11. package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
  12. package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
  13. package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +5 -5
  16. package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
  17. package/package.json +1 -1
  18. package/src/__tests__/helpers/app.ts +15 -4
  19. package/src/app.tsx +8 -0
  20. package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
  21. package/src/client/tiptap/extensions.ts +3 -0
  22. package/src/client/tiptap/insert-paragraph-around.ts +79 -0
  23. package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
  24. package/src/db/migrations/0019_bored_magus.sql +2 -0
  25. package/src/db/migrations/meta/0018_snapshot.json +2225 -0
  26. package/src/db/migrations/meta/0019_snapshot.json +2238 -0
  27. package/src/db/migrations/meta/_journal.json +15 -1
  28. package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
  29. package/src/db/migrations/pg/0017_bright_beyonder.sql +2 -0
  30. package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
  31. package/src/db/migrations/pg/meta/0017_snapshot.json +2862 -0
  32. package/src/db/migrations/pg/meta/_journal.json +15 -1
  33. package/src/db/pg/schema.ts +22 -0
  34. package/src/db/schema.ts +27 -0
  35. package/src/index.ts +1 -2
  36. package/src/lib/__tests__/hosted-signin.test.ts +30 -0
  37. package/src/lib/__tests__/navigation.test.ts +4 -20
  38. package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
  39. package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
  40. package/src/lib/__tests__/summary.test.ts +140 -0
  41. package/src/lib/__tests__/view.test.ts +66 -0
  42. package/src/lib/feed.ts +70 -34
  43. package/src/lib/hosted-signin.ts +9 -3
  44. package/src/lib/icons.ts +37 -0
  45. package/src/lib/navigation.ts +11 -12
  46. package/src/lib/post-meta.ts +20 -2
  47. package/src/lib/rate-limit-d1.ts +99 -0
  48. package/src/lib/rate-limit-memory.ts +105 -0
  49. package/src/lib/rate-limit.ts +63 -0
  50. package/src/lib/render.tsx +9 -0
  51. package/src/lib/resolve-config.ts +9 -0
  52. package/src/lib/summary.ts +42 -7
  53. package/src/lib/url.ts +34 -0
  54. package/src/lib/view.ts +42 -8
  55. package/src/middleware/__tests__/auth.test.ts +44 -4
  56. package/src/middleware/__tests__/rate-limit.test.ts +113 -0
  57. package/src/middleware/__tests__/session.test.ts +85 -0
  58. package/src/middleware/auth.ts +62 -25
  59. package/src/middleware/rate-limit.ts +54 -0
  60. package/src/middleware/session.ts +36 -0
  61. package/src/routes/__tests__/compose.test.ts +1 -1
  62. package/src/routes/api/__tests__/search.test.ts +48 -0
  63. package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
  64. package/src/routes/api/internal/search-reindex.ts +40 -0
  65. package/src/routes/api/internal/sites.ts +1 -0
  66. package/src/routes/api/search.ts +13 -0
  67. package/src/routes/auth/dev.ts +1 -1
  68. package/src/routes/auth/signin.tsx +23 -5
  69. package/src/routes/dash/settings.tsx +3 -5
  70. package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
  71. package/src/routes/feed/sitemap.ts +208 -33
  72. package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
  73. package/src/routes/pages/home.tsx +24 -15
  74. package/src/routes/pages/page.tsx +34 -0
  75. package/src/routes/pages/partials.tsx +4 -15
  76. package/src/runtime/cloudflare.ts +4 -0
  77. package/src/runtime/node.ts +16 -0
  78. package/src/services/__tests__/post.test.ts +205 -0
  79. package/src/services/__tests__/search.test.ts +44 -0
  80. package/src/services/__tests__/site-admin.test.ts +85 -0
  81. package/src/services/export.ts +9 -2
  82. package/src/services/post.ts +200 -2
  83. package/src/services/site-admin.ts +66 -1
  84. package/src/styles/ui.css +12 -0
  85. package/src/types/app-context.ts +20 -0
  86. package/src/types/config.ts +8 -0
  87. package/src/types/props.ts +0 -7
  88. package/src/ui/feed/LinkCard.tsx +3 -20
  89. package/src/ui/feed/LinkPreview.tsx +5 -19
  90. package/src/ui/feed/PostStatusBadges.tsx +4 -38
  91. package/src/ui/layouts/BaseLayout.tsx +23 -29
  92. package/src/ui/shared/DecorativeQuoteMark.tsx +2 -13
  93. package/src/ui/shared/Icon.tsx +60 -0
  94. package/src/ui/shared/IconSprite.tsx +57 -0
  95. package/src/ui/shared/PostFooter.tsx +6 -62
  96. package/src/ui/shared/custom-icons.ts +132 -0
  97. package/src/ui/shared/icon-collector.ts +37 -0
  98. package/dist/app-DzCB4yOp.js +0 -5
  99. package/dist/client/_assets/client-C_kImWZj.css +0 -2
@@ -45,4 +45,89 @@ describe("SiteAdminService", () => {
45
45
 
46
46
  expect(siteRows).toEqual([{ key: "demo-cloud" }]);
47
47
  });
48
+
49
+ it("returns the existing site when replayed with the same idempotency key", async () => {
50
+ const { db } = createTestDatabase();
51
+ const service = createSiteAdminService(db, sqliteSchemaBundle, "sqlite", {
52
+ siteResolutionMode: "host-based",
53
+ });
54
+
55
+ const first = await service.createManagedSite({
56
+ key: "idem-site",
57
+ primaryHost: "idem-site.example.com",
58
+ siteName: "Idempotent Site",
59
+ idempotencyKey: "job_abc",
60
+ });
61
+
62
+ const second = await service.createManagedSite({
63
+ key: "idem-site",
64
+ primaryHost: "idem-site.example.com",
65
+ siteName: "Idempotent Site",
66
+ idempotencyKey: "job_abc",
67
+ });
68
+
69
+ expect(second.site.id).toBe(first.site.id);
70
+ expect(second.domain.id).toBe(first.domain.id);
71
+ });
72
+
73
+ it("rejects reuse of an idempotency key with different key or primary host", async () => {
74
+ const { db } = createTestDatabase();
75
+ const service = createSiteAdminService(db, sqliteSchemaBundle, "sqlite", {
76
+ siteResolutionMode: "host-based",
77
+ });
78
+
79
+ await service.createManagedSite({
80
+ key: "idem-site",
81
+ primaryHost: "idem-site.example.com",
82
+ siteName: "Idempotent Site",
83
+ idempotencyKey: "job_xyz",
84
+ });
85
+
86
+ await expect(
87
+ service.createManagedSite({
88
+ key: "other-site",
89
+ primaryHost: "idem-site.example.com",
90
+ siteName: "Other Site",
91
+ idempotencyKey: "job_xyz",
92
+ }),
93
+ ).rejects.toEqual(
94
+ new ConflictError(
95
+ "Idempotency key was reused with a different site key or primary host.",
96
+ ),
97
+ );
98
+
99
+ await expect(
100
+ service.createManagedSite({
101
+ key: "idem-site",
102
+ primaryHost: "different-host.example.com",
103
+ siteName: "Idempotent Site",
104
+ idempotencyKey: "job_xyz",
105
+ }),
106
+ ).rejects.toEqual(
107
+ new ConflictError(
108
+ "Idempotency key was reused with a different site key or primary host.",
109
+ ),
110
+ );
111
+ });
112
+
113
+ it("treats requests without an idempotency key as independent creations", async () => {
114
+ const { db } = createTestDatabase();
115
+ const service = createSiteAdminService(db, sqliteSchemaBundle, "sqlite", {
116
+ siteResolutionMode: "host-based",
117
+ });
118
+
119
+ await service.createManagedSite({
120
+ key: "no-idem-site",
121
+ primaryHost: "no-idem-site.example.com",
122
+ siteName: "No Idem Site",
123
+ });
124
+
125
+ await expect(
126
+ service.createManagedSite({
127
+ key: "no-idem-site",
128
+ primaryHost: "no-idem-site-2.example.com",
129
+ siteName: "No Idem Site",
130
+ }),
131
+ ).rejects.toEqual(new ConflictError("Site key is already in use."));
132
+ });
48
133
  });
@@ -28,6 +28,7 @@ import {
28
28
  getDefaultJantFaviconIcoBytes,
29
29
  } from "../lib/jant-branding.js";
30
30
  import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
31
+ import { extractBodyText } from "../lib/summary.js";
31
32
  import { getMediaUrl, getPublicUrlForProvider } from "../lib/image.js";
32
33
  import { render as renderMarkdown } from "../lib/markdown.js";
33
34
  import { formatRelativeAge, toISOString } from "../lib/time.js";
@@ -1074,10 +1075,16 @@ function getArchiveSummaryText(post: Post): string | null {
1074
1075
  // serialized as `link_url` and rendered as a domain badge, so using
1075
1076
  // it as `summary_text` would duplicate that information.
1076
1077
  // - Note: body only. If the body is empty, there's nothing to describe.
1078
+ //
1079
+ // Note: we re-derive the body text from `post.body` (TipTap JSON) rather
1080
+ // than reusing `post.bodyText`, because `bodyText` is written with
1081
+ // `includeLinkHrefs: true` for FTS search indexing — that pollutes the
1082
+ // stored text with trailing link URLs. Here we need clean prose.
1083
+ const cleanBodyText = post.body ? extractBodyText(post.body) : null;
1077
1084
  const candidates =
1078
1085
  post.format === "quote"
1079
- ? [post.summary, post.bodyText, post.quoteText]
1080
- : [post.summary, post.bodyText];
1086
+ ? [post.summary, cleanBodyText, post.quoteText]
1087
+ : [post.summary, cleanBodyText];
1081
1088
 
1082
1089
  for (const candidate of candidates) {
1083
1090
  const normalized = normalizeArchiveText(candidate);
@@ -19,6 +19,7 @@ import {
19
19
  isNotNull,
20
20
  asc,
21
21
  lte,
22
+ gt,
22
23
  } from "drizzle-orm";
23
24
  import {
24
25
  type Database,
@@ -156,6 +157,17 @@ export interface PostBodyContent {
156
157
  chars: number;
157
158
  }
158
159
 
160
+ /** Minimal projection used by the sitemap renderer. */
161
+ export interface SitemapPostEntry {
162
+ id: string;
163
+ /** Canonical slug from `path_registry` */
164
+ slug: string;
165
+ /** Primary alias, if the post has one; used in preference to `slug` for URLs */
166
+ alias: string | null;
167
+ updatedAt: number;
168
+ featuredAt: number | null;
169
+ }
170
+
159
171
  export interface PostService {
160
172
  getById(id: string): Promise<Post | null>;
161
173
  getBodyContent(id: string): Promise<PostBodyContent | null>;
@@ -167,6 +179,35 @@ export interface PostService {
167
179
  }): Promise<string>;
168
180
  checkSlugAvailability(slug: string, excludePostId?: string): Promise<boolean>;
169
181
  list(filters?: PostFilters): Promise<Post[]>;
182
+ /**
183
+ * List minimal fields needed to render sitemap entries, paginated by `id`
184
+ * (ascending). Excludes replies, private posts, deleted posts, and drafts.
185
+ *
186
+ * Uses keyset pagination on the primary key so old sitemap shards are cheap
187
+ * to serve and stable across shard boundaries: a newly created post always
188
+ * gets a larger TypeID than any previously-committed post, so it lands in
189
+ * the last shard and never rewrites older ones.
190
+ *
191
+ * @param options.afterId Exclusive lower bound on `id`. Omit for the first
192
+ * shard.
193
+ * @param options.limit Maximum rows to return.
194
+ */
195
+ listForSitemap(options: {
196
+ afterId?: string;
197
+ limit: number;
198
+ }): Promise<SitemapPostEntry[]>;
199
+ /** Count posts that qualify for the sitemap (same filters as `listForSitemap`) */
200
+ countForSitemap(): Promise<number>;
201
+ /**
202
+ * Return the id at the given 0-based offset in the sitemap ordering.
203
+ * Used to compute keyset cursors for sharded sitemap endpoints.
204
+ *
205
+ * Returns `null` when the offset is beyond the available rows.
206
+ *
207
+ * Walks the primary-key index with `ORDER BY id ASC LIMIT 1 OFFSET ?` —
208
+ * SQLite/D1 scan only the index for this, not the row data.
209
+ */
210
+ getSitemapIdAt(offset: number): Promise<string | null>;
170
211
  /** Count posts matching filters (ignores cursor, offset, limit) */
171
212
  count(filters?: PostFilters): Promise<number>;
172
213
  /** Count posts matching filters up to a fixed limit (ignores cursor, offset, limit) */
@@ -292,6 +333,28 @@ export interface PostService {
292
333
  getDistinctYears(filters?: PostFilters): Promise<number[]>;
293
334
  /** For each thread ID, return the ID of the last published, non-deleted post */
294
335
  getLastPostIdsByThread(threadIds: string[]): Promise<Map<string, string>>;
336
+ /**
337
+ * Rebuild `post.body_text` for a batch of non-deleted posts, cursor-paginated
338
+ * by post id. For each row, recomputes the plain-text extraction via
339
+ * `extractBodyText(body)` and writes it back only when it differs from the
340
+ * stored value. FTS indexes (SQLite trigger / Postgres generated column)
341
+ * refresh automatically on the UPDATE.
342
+ *
343
+ * Idempotent: re-running after a no-op pass returns `updated: 0`.
344
+ *
345
+ * @param options.limit Batch size (1..500, default 50)
346
+ * @param options.cursor Exclusive lower bound on post id; pass the previous
347
+ * response's `nextCursor` to continue
348
+ * @returns processed/updated/skipped counts, the next cursor, and a `done`
349
+ * flag the caller uses to terminate the loop
350
+ */
351
+ reindexBodyText(options?: { limit?: number; cursor?: string }): Promise<{
352
+ processed: number;
353
+ updated: number;
354
+ skipped: number;
355
+ nextCursor: string | null;
356
+ done: boolean;
357
+ }>;
295
358
  }
296
359
 
297
360
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
@@ -1300,6 +1363,83 @@ export function createPostService(
1300
1363
  return hydratePosts(rows);
1301
1364
  },
1302
1365
 
1366
+ async listForSitemap({ afterId, limit }) {
1367
+ // Share the filter conditions with `list()` so visibility/reply/deleted
1368
+ // semantics stay consistent if they ever change.
1369
+ const conditions = buildFilterConditions({
1370
+ status: "published",
1371
+ excludePrivate: true,
1372
+ excludeReplies: true,
1373
+ });
1374
+ if (afterId !== undefined) {
1375
+ conditions.push(sql`${posts.id} > ${afterId}`);
1376
+ }
1377
+
1378
+ const rows = await db
1379
+ .select({
1380
+ id: posts.id,
1381
+ updatedAt: posts.updatedAt,
1382
+ featuredAt: posts.featuredAt,
1383
+ })
1384
+ .from(posts)
1385
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
1386
+ .orderBy(asc(posts.id))
1387
+ .limit(limit);
1388
+
1389
+ if (rows.length === 0) return [];
1390
+
1391
+ const ids = rows.map((row) => row.id);
1392
+ const [slugMap, aliasesMap] = await Promise.all([
1393
+ resolvedPaths.getPostSlugMap(ids),
1394
+ resolvedPaths.getPostAliases(ids),
1395
+ ]);
1396
+
1397
+ return rows
1398
+ .map((row): SitemapPostEntry | null => {
1399
+ const slug = slugMap.get(row.id);
1400
+ if (!slug) return null;
1401
+ const alias = aliasesMap.get(row.id)?.[0] ?? null;
1402
+ return {
1403
+ id: row.id,
1404
+ slug,
1405
+ alias,
1406
+ updatedAt: row.updatedAt,
1407
+ featuredAt: row.featuredAt,
1408
+ };
1409
+ })
1410
+ .filter((entry): entry is SitemapPostEntry => entry !== null);
1411
+ },
1412
+
1413
+ async countForSitemap() {
1414
+ const conditions = buildFilterConditions({
1415
+ status: "published",
1416
+ excludePrivate: true,
1417
+ excludeReplies: true,
1418
+ });
1419
+ const result = await db
1420
+ .select({ count: sql<number>`CAST(count(*) AS INTEGER)`.as("count") })
1421
+ .from(posts)
1422
+ .where(conditions.length > 0 ? and(...conditions) : undefined);
1423
+ return result[0]?.count ?? 0;
1424
+ },
1425
+
1426
+ async getSitemapIdAt(offset) {
1427
+ if (offset < 0) return null;
1428
+ const conditions = buildFilterConditions({
1429
+ status: "published",
1430
+ excludePrivate: true,
1431
+ excludeReplies: true,
1432
+ });
1433
+ const rows = await db
1434
+ .select({ id: posts.id })
1435
+ .from(posts)
1436
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
1437
+ .orderBy(asc(posts.id))
1438
+ .limit(1)
1439
+ .offset(offset);
1440
+ return rows[0]?.id ?? null;
1441
+ },
1442
+
1303
1443
  async count(filters = {}) {
1304
1444
  const conditions = buildFilterConditions(filters);
1305
1445
 
@@ -1377,7 +1517,9 @@ export function createPostService(
1377
1517
  });
1378
1518
 
1379
1519
  const bodyHtml = body ? renderTiptapJson(body) : null;
1380
- const bodyText = body ? extractBodyText(body) : null;
1520
+ const bodyText = body
1521
+ ? extractBodyText(body, { includeLinkHrefs: true })
1522
+ : null;
1381
1523
 
1382
1524
  // Generate summary for titled notes with body content
1383
1525
  let summary: string | null = null;
@@ -1879,7 +2021,7 @@ export function createPostService(
1879
2021
  ? renderTiptapJson(normalizedBody)
1880
2022
  : null;
1881
2023
  updates.bodyText = normalizedBody
1882
- ? extractBodyText(normalizedBody)
2024
+ ? extractBodyText(normalizedBody, { includeLinkHrefs: true })
1883
2025
  : null;
1884
2026
  }
1885
2027
 
@@ -2967,5 +3109,61 @@ export function createPostService(
2967
3109
 
2968
3110
  return rows.map((r) => parseInt(r.year, 10));
2969
3111
  },
3112
+
3113
+ async reindexBodyText(options = {}) {
3114
+ const requested = options.limit ?? 50;
3115
+ const limit = Math.min(Math.max(Math.trunc(requested), 1), 500);
3116
+ const cursor = options.cursor;
3117
+
3118
+ const whereConditions = [
3119
+ eq(posts.siteId, siteId),
3120
+ isNull(posts.deletedAt),
3121
+ ];
3122
+ if (cursor) whereConditions.push(gt(posts.id, cursor));
3123
+
3124
+ // Fetch one extra row to detect end-of-data without a separate COUNT.
3125
+ const rows = await db
3126
+ .select({
3127
+ id: posts.id,
3128
+ body: posts.body,
3129
+ bodyText: posts.bodyText,
3130
+ })
3131
+ .from(posts)
3132
+ .where(and(...whereConditions))
3133
+ .orderBy(asc(posts.id))
3134
+ .limit(limit + 1);
3135
+
3136
+ const hasMore = rows.length > limit;
3137
+ const batch = hasMore ? rows.slice(0, limit) : rows;
3138
+
3139
+ let updated = 0;
3140
+ let skipped = 0;
3141
+
3142
+ for (const row of batch) {
3143
+ const nextBodyText = row.body
3144
+ ? extractBodyText(row.body, { includeLinkHrefs: true })
3145
+ : null;
3146
+ if (nextBodyText === row.bodyText) {
3147
+ skipped++;
3148
+ continue;
3149
+ }
3150
+ await db
3151
+ .update(posts)
3152
+ .set({ bodyText: nextBodyText })
3153
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, row.id)));
3154
+ updated++;
3155
+ }
3156
+
3157
+ const lastRow = batch.at(-1);
3158
+ const lastId = lastRow ? lastRow.id : null;
3159
+
3160
+ return {
3161
+ processed: batch.length,
3162
+ updated,
3163
+ skipped,
3164
+ nextCursor: hasMore ? lastId : null,
3165
+ done: !hasMore,
3166
+ };
3167
+ },
2970
3168
  };
2971
3169
  }
@@ -1,4 +1,4 @@
1
- import { eq, sql } from "drizzle-orm";
1
+ import { and, eq, sql } from "drizzle-orm";
2
2
  import {
3
3
  executeStatement,
4
4
  type Database,
@@ -45,6 +45,13 @@ export interface CreateManagedSiteInput {
45
45
  siteName: string;
46
46
  siteLanguage?: string | null;
47
47
  timeZone?: string | null;
48
+ /**
49
+ * Optional caller-supplied idempotency key. When provided, retrying the same
50
+ * request returns the previously created site instead of a 409 conflict.
51
+ * Reusing the key with a different `key` or `primaryHost` is rejected as a
52
+ * client bug.
53
+ */
54
+ idempotencyKey?: string | null;
48
55
  }
49
56
 
50
57
  export interface ManagedSiteResult {
@@ -194,6 +201,47 @@ export function createSiteAdminService(
194
201
  return `${protocol}//${domain.host}${pathPrefix}`;
195
202
  }
196
203
 
204
+ async function loadByIdempotencyKey(
205
+ targetDb: Database,
206
+ idempotencyKey: string,
207
+ ): Promise<ManagedSiteResult | null> {
208
+ const siteRow = (
209
+ await targetDb
210
+ .select()
211
+ .from(sites)
212
+ .where(eq(sites.provisioningIdempotencyKey, idempotencyKey))
213
+ .limit(1)
214
+ )[0];
215
+ if (!siteRow) {
216
+ return null;
217
+ }
218
+
219
+ const domainRow = (
220
+ await targetDb
221
+ .select()
222
+ .from(siteDomains)
223
+ .where(
224
+ and(
225
+ eq(siteDomains.siteId, siteRow.id),
226
+ eq(siteDomains.kind, "primary"),
227
+ ),
228
+ )
229
+ .limit(1)
230
+ )[0];
231
+ if (!domainRow) {
232
+ // A site row without a primary domain means the original creation aborted
233
+ // mid-transaction on a dialect without real transactions. Treat as not
234
+ // found so the caller can retry; the partial unique index will surface a
235
+ // genuine duplicate.
236
+ return null;
237
+ }
238
+
239
+ return {
240
+ site: toSite(siteRow),
241
+ domain: toSiteDomain(domainRow),
242
+ };
243
+ }
244
+
197
245
  async function createWithDatabase(
198
246
  targetDb: Database,
199
247
  input: CreateManagedSiteInput,
@@ -201,6 +249,22 @@ export function createSiteAdminService(
201
249
  const siteKey = input.key.trim();
202
250
  const primaryHost = input.primaryHost.trim().toLowerCase();
203
251
  const siteName = input.siteName.trim();
252
+ const idempotencyKey = input.idempotencyKey?.trim() || null;
253
+
254
+ if (idempotencyKey) {
255
+ const existing = await loadByIdempotencyKey(targetDb, idempotencyKey);
256
+ if (existing) {
257
+ if (
258
+ existing.site.key !== siteKey ||
259
+ existing.domain.host !== primaryHost
260
+ ) {
261
+ throw new ConflictError(
262
+ "Idempotency key was reused with a different site key or primary host.",
263
+ );
264
+ }
265
+ return existing;
266
+ }
267
+ }
204
268
 
205
269
  const existingSite = await targetDb
206
270
  .select({ id: sites.id })
@@ -231,6 +295,7 @@ export function createSiteAdminService(
231
295
  id: siteId,
232
296
  key: siteKey,
233
297
  status: "active",
298
+ provisioningIdempotencyKey: idempotencyKey,
234
299
  createdAt: timestamp,
235
300
  updatedAt: timestamp,
236
301
  })
package/src/styles/ui.css CHANGED
@@ -2100,6 +2100,10 @@
2100
2100
  width: 30px;
2101
2101
  height: 9px;
2102
2102
  margin: 4rem 0;
2103
+ /* Center over the post-body column. On desktop the body column is
2104
+ `var(--layout-content-width)` (55%). Between 760-1024px it switches
2105
+ to `min(100%, 35rem)` via preset.css, so the hr must track that
2106
+ same rule — otherwise the glyph drifts left of the content center. */
2103
2107
  margin-left: calc(var(--layout-content-width) / 2 - 15px);
2104
2108
  color: var(--site-feed-divider-color);
2105
2109
  background-color: currentColor;
@@ -2113,6 +2117,14 @@
2113
2117
  mask-size: contain;
2114
2118
  }
2115
2119
 
2120
+ @media (max-width: 1024px) {
2121
+ .site-content hr.feed-divider {
2122
+ /* Match preset.css `min(100%, 35rem)` body-column width so the hr
2123
+ stays visually aligned with the post column at this breakpoint. */
2124
+ margin-left: calc(min(100%, 35rem) / 2 - 15px);
2125
+ }
2126
+ }
2127
+
2116
2128
  .feed-card {
2117
2129
  position: relative;
2118
2130
  padding: 1rem 1.1rem 0.95rem;
@@ -11,10 +11,17 @@ import type { Services } from "../services/index.js";
11
11
  import type { HostedHandoffService } from "../services/hosted-handoff.js";
12
12
  import type { Auth } from "../auth.js";
13
13
  import type { AppConfig } from "./config.js";
14
+ import type { RateLimiter } from "../lib/rate-limit.js";
14
15
  import type { StorageDriver } from "../lib/storage.js";
15
16
  import type { Bindings } from "./bindings.js";
16
17
  import type { Site, SiteDomain } from "./entities.js";
17
18
 
19
+ /**
20
+ * Session payload as returned by better-auth's `getSession`.
21
+ * Populated once per request by the `attachSession` middleware.
22
+ */
23
+ export type AppSession = Awaited<ReturnType<Auth["api"]["getSession"]>>;
24
+
18
25
  export interface AppVariables {
19
26
  services: Services;
20
27
  hostedHandoff: HostedHandoffService;
@@ -27,6 +34,19 @@ export interface AppVariables {
27
34
  storage: StorageDriver | null;
28
35
  publicRequestUrl: string;
29
36
  publicPath: string;
37
+ /**
38
+ * Cached session for the current request. `null` when unauthenticated or
39
+ * when the session lookup errored. Populated by `attachSession` middleware.
40
+ */
41
+ session: AppSession;
42
+ /** True when `session?.user` is set. Shortcut for the common read. */
43
+ isAuthenticated: boolean;
44
+ /**
45
+ * Runtime-appropriate rate limiter. Populated per-request from the
46
+ * runtime (D1 on Workers, in-memory on Node). Middleware calls
47
+ * `c.var.rateLimiter.check(...)` instead of caring which impl is used.
48
+ */
49
+ rateLimiter: RateLimiter;
30
50
  }
31
51
 
32
52
  export type App = Hono<{ Bindings: Bindings; Variables: AppVariables }>;
@@ -476,6 +476,14 @@ export interface AppConfig {
476
476
  siteAvatarUrl: string;
477
477
  faviconVersion: string;
478
478
 
479
+ // Rate limiting (ENV only)
480
+ rateLimit: {
481
+ /** When true, all rate-limit middleware becomes a no-op. */
482
+ disabled: boolean;
483
+ /** Per-IP cap for `/api/search` requests per 60-second window. */
484
+ searchPerMinute: number;
485
+ };
486
+
479
487
  // Settings form placeholders (ENV > Default, without DB)
480
488
  fallbacks: {
481
489
  siteName: string;
@@ -138,13 +138,6 @@ export interface FeedData {
138
138
  posts: FeedPostView[];
139
139
  }
140
140
 
141
- /** Data passed to sitemap renderers */
142
- export interface SitemapData {
143
- siteUrl: string;
144
- sitemapUrl: string;
145
- posts: PostView[];
146
- }
147
-
148
141
  // =============================================================================
149
142
  // Timeline Types
150
143
  // =============================================================================
@@ -14,6 +14,7 @@ import { PostStatusBadges } from "./PostStatusBadges.js";
14
14
  import { sanitizeUrl, extractDisplayDomain } from "../../lib/url.js";
15
15
  import { MediaGallery } from "../shared/MediaGallery.js";
16
16
  import { LinkPreview } from "./LinkPreview.js";
17
+ import { Icon } from "../shared/Icon.js";
17
18
 
18
19
  export const LinkCard: FC<TimelineCardProps> = ({
19
20
  post,
@@ -39,30 +40,12 @@ export const LinkCard: FC<TimelineCardProps> = ({
39
40
  target="_blank"
40
41
  rel="noopener noreferrer"
41
42
  >
42
- <svg
43
- class="feed-link-domain-icon"
44
- xmlns="http://www.w3.org/2000/svg"
45
- fill="none"
46
- viewBox="0 0 24 24"
47
- stroke-width="2"
48
- stroke="currentColor"
49
- >
50
- <path d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
51
- </svg>
43
+ <Icon name="link-domain" class="feed-link-domain-icon" />
52
44
  <span>{domain}</span>
53
45
  </a>
54
46
  ) : (
55
47
  <div class="feed-link-domain">
56
- <svg
57
- class="feed-link-domain-icon"
58
- xmlns="http://www.w3.org/2000/svg"
59
- fill="none"
60
- viewBox="0 0 24 24"
61
- stroke-width="2"
62
- stroke="currentColor"
63
- >
64
- <path d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
65
- </svg>
48
+ <Icon name="link-domain" class="feed-link-domain-icon" />
66
49
  <span>{domain}</span>
67
50
  </div>
68
51
  ));
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { FC } from "hono/jsx";
9
+ import { Icon } from "../shared/Icon.js";
9
10
 
10
11
  interface LinkPreviewProps {
11
12
  imageUrl: string;
@@ -41,31 +42,16 @@ export const LinkPreview: FC<LinkPreviewProps> = ({
41
42
  <img src={imageUrl} alt="" class="link-preview-image" loading="lazy" />
42
43
  {isVideo && (
43
44
  <div class="link-preview-play" aria-hidden="true">
44
- <svg
45
- class="link-preview-play-icon"
46
- viewBox="0 0 68 48"
47
- xmlns="http://www.w3.org/2000/svg"
48
- >
49
- <path
50
- class="link-preview-play-bg"
51
- d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z"
52
- fill="rgba(0,0,0,.65)"
53
- />
54
- <path d="M45 24L27 14v20" fill="#fff" />
55
- </svg>
45
+ <Icon name="link-preview-play" class="link-preview-play-icon" />
56
46
  </div>
57
47
  )}
58
48
  {providerLabel && (
59
49
  <span class="link-preview-badge" aria-hidden="true">
60
50
  {isVideo && (
61
- <svg
51
+ <Icon
52
+ name="link-preview-badge-play"
62
53
  class="link-preview-badge-icon"
63
- viewBox="0 0 16 16"
64
- xmlns="http://www.w3.org/2000/svg"
65
- fill="currentColor"
66
- >
67
- <path d="M5.5 3.5v9l7-4.5z" />
68
- </svg>
54
+ />
69
55
  )}
70
56
  {providerLabel}
71
57
  </span>