@acms-subir/core 0.3.10 → 0.3.11

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 (95) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +2 -1
  4. package/cli/main.mjs +11 -3
  5. package/cli/migrate.mjs +102 -0
  6. package/cli/migrations.generated.mjs +1044 -0
  7. package/cli/preview.mjs +1 -1
  8. package/deploy/Dockerfile +5 -1
  9. package/deploy/compose.yml +21 -0
  10. package/docs/live-video.md +1 -1
  11. package/docs/theme-authoring.md +6 -0
  12. package/integration.mjs +39 -0
  13. package/migrate.mjs +5 -0
  14. package/package.json +9 -11
  15. package/src/actions/index.ts +5 -5
  16. package/src/components/DesignManager.tsx +29 -10
  17. package/src/components/EmailSettings.tsx +53 -11
  18. package/src/components/ThemeTransfer.tsx +211 -0
  19. package/src/components/auth/ClerkSession.astro +80 -0
  20. package/src/components/auth/ReverifyForms.astro +61 -0
  21. package/src/components/auth/clerk.ts +169 -0
  22. package/src/components/import/ImportExport.tsx +16 -6
  23. package/src/components/import/runner.ts +3 -0
  24. package/src/layouts/Admin.astro +40 -9
  25. package/src/layouts/Site.astro +6 -1
  26. package/src/lib/api.ts +6 -3
  27. package/src/lib/auth/accounts.ts +94 -0
  28. package/src/lib/auth/config.ts +22 -0
  29. package/src/lib/auth/login-limit.ts +15 -0
  30. package/src/lib/auth/request.ts +161 -0
  31. package/src/lib/auth/store.ts +72 -0
  32. package/src/lib/auth.ts +3 -0
  33. package/src/lib/backup/archive.ts +26 -0
  34. package/src/lib/backup/offsite.ts +173 -0
  35. package/src/lib/backup/store.ts +28 -0
  36. package/src/lib/cache/config.ts +7 -0
  37. package/src/lib/cache/memory.ts +39 -0
  38. package/src/lib/cache/policy.ts +89 -0
  39. package/src/lib/cache/purge.ts +89 -0
  40. package/src/lib/designPreview.ts +2 -2
  41. package/src/lib/dynamicBlocks.ts +1 -1
  42. package/src/lib/editor/auth.ts +35 -5
  43. package/src/lib/editor/http.ts +4 -1
  44. package/src/lib/editor/store.ts +13 -7
  45. package/src/lib/editor/token-route.ts +2 -1
  46. package/src/lib/email/accounts.ts +4 -0
  47. package/src/lib/email/service.ts +1 -0
  48. package/src/lib/email/templates.ts +2 -1
  49. package/src/lib/email/types.ts +1 -0
  50. package/src/lib/mobile/http.ts +4 -3
  51. package/src/lib/mobile/route.ts +41 -4
  52. package/src/lib/net.ts +82 -14
  53. package/src/lib/repo/migrate-runner.ts +96 -0
  54. package/src/lib/repo/migrations.ts +76 -0
  55. package/src/lib/repo/sql.ts +56 -24
  56. package/src/lib/repo/sqlite.ts +14 -1
  57. package/src/lib/repo/types.ts +12 -1
  58. package/src/lib/scheduler.ts +7 -0
  59. package/src/lib/theme-transfer.ts +163 -0
  60. package/src/lib/theme.ts +5 -0
  61. package/src/lib/webhooks.ts +7 -2
  62. package/src/middleware.ts +41 -11
  63. package/src/pages/[...slug].astro +3 -2
  64. package/src/pages/account/[action].astro +6 -0
  65. package/src/pages/admin/app-auth.astro +21 -7
  66. package/src/pages/admin/design.astro +2 -1
  67. package/src/pages/admin/import.astro +5 -3
  68. package/src/pages/admin/index.astro +3 -2
  69. package/src/pages/admin/integrations.astro +3 -2
  70. package/src/pages/admin/login.astro +236 -25
  71. package/src/pages/admin/logout.ts +16 -2
  72. package/src/pages/admin/settings.astro +31 -5
  73. package/src/pages/admin/users.astro +350 -59
  74. package/src/pages/api/backup.ts +10 -26
  75. package/src/pages/api/design/export.ts +72 -0
  76. package/src/pages/api/design/import.ts +41 -0
  77. package/src/pages/api/email/settings.ts +9 -5
  78. package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
  79. package/src/pages/api/live/settings.ts +2 -2
  80. package/src/pages/api/media/upload.ts +2 -2
  81. package/src/pages/api/push/admin.ts +3 -2
  82. package/src/pages/api/restore.ts +6 -3
  83. package/src/pages/api/v1/calendars.ts +1 -1
  84. package/src/pages/api/v1/links.ts +1 -1
  85. package/src/pages/api/v1/openapi.json.ts +1 -1
  86. package/src/pages/api/v1/push/topics.ts +1 -1
  87. package/src/pages/api/v1/resources.ts +1 -1
  88. package/src/pages/api/v1/theme.ts +1 -1
  89. package/src/pages/blog/[slug].astro +3 -2
  90. package/src/pages/events/[slug].astro +3 -2
  91. package/src/pages/preview/[type]/[id].astro +3 -3
  92. package/src/styles/admin.css +103 -16
  93. package/src/styles/email-settings.css +6 -0
  94. package/src/virtual.d.ts +1 -0
  95. package/src/worker.ts +7 -1
@@ -1,8 +1,11 @@
1
+ import { AUTH } from '../auth/config';
2
+ import { AuthStore } from '../auth/store';
1
3
  import { FormStore, formInsert, submissionInsert } from '../forms/store';
2
4
  import { GuestStore } from '../mobile/guest-store';
3
5
  import { LiveStore } from '../live/store';
4
6
  import { PushStore } from '../push/store';
5
7
  import { EditorStore } from '../editor/store';
8
+ import { BackupStore } from '../backup/store';
6
9
  import { LiveConfig } from '../live/types';
7
10
  import { ApiError } from '../api';
8
11
  import { EmailStore, bookingJobs } from '../email/store';
@@ -11,7 +14,7 @@ import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings,
11
14
  import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
12
15
  import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
13
16
  import { cleanRrule } from '../calendar/rrule';
14
- import { MIGRATIONS, MIGRATIONS_TABLE } from './migrations';
17
+ import { runMigrations } from './migrate-runner';
15
18
  import { htmlToPlaintext } from '../plaintext';
16
19
  import { SEED } from '../brand';
17
20
  import { CONTENT, postsIndexUrl } from '../content';
@@ -258,12 +261,14 @@ function safeSnippet(raw: unknown): string {
258
261
  export class SqlRepository implements ContentRepository {
259
262
  private driver!: SqlDriver;
260
263
  private ready: Promise<void>;
264
+ readonly auth: AuthStore;
261
265
  readonly email: EmailStore;
262
266
  readonly forms: FormStore;
263
267
  readonly guests: GuestStore;
264
268
  readonly live: LiveStore;
265
269
  readonly push: PushStore;
266
270
  readonly editor: EditorStore;
271
+ readonly backups: BackupStore;
267
272
 
268
273
  constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
269
274
  this.ready = this.init(driver);
@@ -272,6 +277,8 @@ export class SqlRepository implements ContentRepository {
272
277
  this.live = new LiveStore(async () => { await this.ready; return this.driver; });
273
278
  this.push = new PushStore(async () => { await this.ready; return this.driver; });
274
279
  this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
280
+ this.backups = new BackupStore(async () => { await this.ready; return this.driver; });
281
+ this.auth = new AuthStore(async () => { await this.ready; return this.driver; });
275
282
  this.email = new EmailStore(async () => { await this.ready; return this.driver; });
276
283
  }
277
284
 
@@ -287,27 +294,11 @@ export class SqlRepository implements ContentRepository {
287
294
  }
288
295
 
289
296
  private async migrate() {
290
- await this.driver.run(MIGRATIONS_TABLE);
291
- const r = await this.first('SELECT MAX(version) AS v FROM migrations');
292
- let applied = Number(r?.v ?? 0);
293
- if (applied === 0) {
294
- // Pre-migrations DB: the baseline tables already exist, mark v1 applied.
295
- const baseline = await this.first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'pages'");
296
- if (baseline) {
297
- await this.driver.run('INSERT INTO migrations (version, applied_at) VALUES (1, ?)', [Date.now()]);
298
- applied = 1;
299
- }
300
- }
301
- const pending = MIGRATIONS.filter((m) => m.version > applied);
302
- for (const m of pending) {
303
- await this.driver.batch([
304
- ...m.statements.map((sql) => ({ sql })),
305
- { sql: 'INSERT INTO migrations (version, applied_at) VALUES (?, ?)', args: [m.version, Date.now()] },
306
- ]);
307
- }
297
+ // under the shared migration lock (also used by `acms migrate`)
298
+ const { applied } = await runMigrations(this.driver);
308
299
  // v21 introduced the search index; plaintext can't be derived in SQL, so
309
300
  // the backfill runs here once, right after that version lands.
310
- if (pending.some((m) => m.version === 21)) await this.reindexSearch();
301
+ if (applied.includes(21)) await this.reindexSearch();
311
302
  }
312
303
 
313
304
  private async seed() {
@@ -320,7 +311,8 @@ export class SqlRepository implements ContentRepository {
320
311
  { id: uuid(), type: 'text', config: { markdown: 'This home page was built in the **widget editor** — open `/admin` to change it.' } },
321
312
  ];
322
313
  await this.driver.batch([
323
- { sql: 'INSERT INTO settings (id, site_title, tagline) VALUES (1, ?, ?)', args: [SEED.siteTitle, SEED.tagline] },
314
+ // random start: see migration 49
315
+ { sql: 'INSERT INTO settings (id, site_title, tagline, content_version) VALUES (1, ?, ?, abs(random() % 1000000000))', args: [SEED.siteTitle, SEED.tagline] },
324
316
  {
325
317
  sql: 'INSERT INTO pages (id, slug, title, status, is_home, blocks, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?, ?)',
326
318
  args: [uuid(), 'home', 'Home', 'published', JSON.stringify(homeBlocks), now, now],
@@ -1559,6 +1551,23 @@ export class SqlRepository implements ContentRepository {
1559
1551
  await this.driver.run('DELETE FROM snippets WHERE id = ?', [id]);
1560
1552
  }
1561
1553
 
1554
+ private ownerGuard(id: string): SqlStatement[] {
1555
+ if (AUTH.provider !== 'clerk') return [];
1556
+ // Execute in the same write transaction as demotion/deletion: concurrent
1557
+ // owners cannot each remove the other and leave the site without one.
1558
+ return [{ sql: `INSERT INTO auth_owner_checks(allowed) SELECT 0 FROM users target
1559
+ WHERE target.id=? AND target.role='owner' AND EXISTS (
1560
+ SELECT 1 FROM auth_identities a WHERE a.user_id=target.id AND a.disabled=0 AND NOT EXISTS (
1561
+ SELECT 1 FROM users other JOIN auth_identities b ON b.user_id=other.id
1562
+ WHERE other.id<>target.id AND other.role='owner' AND b.disabled=0 AND b.issuer=a.issuer))`, args: [id] }];
1563
+ }
1564
+ private async guardedUserWrite(statements: SqlStatement[]) {
1565
+ try { await this.driver.batch(statements); }
1566
+ catch (error) {
1567
+ if (String(error).includes('keep_clerk_owner')) throw new Error('Keep at least one linked owner. Add another owner before removing or demoting this account.');
1568
+ throw error;
1569
+ }
1570
+ }
1562
1571
  // ---- users ----
1563
1572
  async listUsers() {
1564
1573
  await this.ready;
@@ -1592,16 +1601,28 @@ export class SqlRepository implements ContentRepository {
1592
1601
  if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
1593
1602
  if (!sets.length) return;
1594
1603
  args.push(id);
1595
- await this.driver.batch([
1604
+ await this.guardedUserWrite([
1605
+ ...(data.role !== undefined && data.role !== 'owner' ? this.ownerGuard(id) : []),
1596
1606
  {sql:`UPDATE users SET ${sets.join(', ')} WHERE id = ?`,args},
1597
1607
  ...(data.passHash === undefined ? [] : [{sql:`INSERT INTO email_jobs(id,dedupe,kind,recipient,payload,config_version,next_at,created_at,updated_at)
1598
1608
  SELECT ?,?,'password-changed',email,'{}',COALESCE((SELECT MAX(version) FROM email_configs),0),?,?,? FROM users WHERE id=?`,
1599
1609
  args:[crypto.randomUUID(),crypto.randomUUID(),Date.now(),Date.now(),Date.now(),id]}]),
1600
1610
  ]);
1601
1611
  }
1612
+ async changeUserEmail(id: string, email: string, { signOut, notify }: { signOut: boolean; notify: boolean }) {
1613
+ await this.ready;
1614
+ const now = Date.now();
1615
+ // the notice is queued before the update so it reads the previous address
1616
+ await this.driver.batch([
1617
+ ...(notify ? [{sql:`INSERT INTO email_jobs(id,dedupe,kind,recipient,payload,config_version,next_at,created_at,updated_at)
1618
+ SELECT ?,?,'email-changed',email,'{}',COALESCE((SELECT MAX(version) FROM email_configs),0),?,?,? FROM users WHERE id=? AND email<>?`,
1619
+ args:[crypto.randomUUID(),crypto.randomUUID(),now,now,now,id,email.toLowerCase()]}] : []),
1620
+ {sql:`UPDATE users SET email = ?${signOut ? ', session_version = session_version + 1' : ''} WHERE id = ?`,args:[email.toLowerCase(),id]},
1621
+ ]);
1622
+ }
1602
1623
  async deleteUser(id: string) {
1603
1624
  await this.ready;
1604
- await this.driver.run('DELETE FROM users WHERE id = ?', [id]);
1625
+ await this.guardedUserWrite([...this.ownerGuard(id), { sql: 'DELETE FROM users WHERE id = ?', args: [id] }]);
1605
1626
  }
1606
1627
  async getUserAuth(email: string) {
1607
1628
  await this.ready;
@@ -1735,12 +1756,13 @@ export class SqlRepository implements ContentRepository {
1735
1756
  }
1736
1757
 
1737
1758
  // ---- restore ----
1738
- async restoreAll(content: Parameters<ContentRepository['restoreAll']>[0]) {
1759
+ async restoreAll(content: Parameters<ContentRepository['restoreAll']>[0], authRecovery?: { issuer: string; subject: string; userId: string }) {
1739
1760
  await this.ready;
1740
1761
  const stmts: SqlStatement[] = [
1741
1762
  // Suppression and re-enabling share the restore transaction. A failed
1742
1763
  // restore cannot leave notifications disabled or emit phantom deletes.
1743
1764
  { sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
1765
+ { sql: "UPDATE auth_invitations SET state='revoked' WHERE state<>'accepted'" },
1744
1766
  ...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests', 'import_runs', 'import_records', 'places', 'forms', 'form_submissions'].map((t) => ({
1745
1767
  sql: `DELETE FROM ${t}`,
1746
1768
  })),
@@ -1816,6 +1838,7 @@ export class SqlRepository implements ContentRepository {
1816
1838
  args: [r.id, r.fromPath, r.toPath, r.code, r.createdAt],
1817
1839
  })),
1818
1840
  ];
1841
+ if (authRecovery) stmts.push({ sql: `UPDATE auth_identities SET disabled=0 WHERE issuer=? AND subject=? AND user_id=? AND EXISTS(SELECT 1 FROM users WHERE id=? AND role='owner')`, args: [authRecovery.issuer, authRecovery.subject, authRecovery.userId, authRecovery.userId] });
1819
1842
  const settingsWrite = this.settingsStatement({ ...content.settings, live: { ...LiveConfig.parse(content.settings.live ?? {}), mode: 'off', manual: { watchUrl: '', title: 'We’re live', until: 0 } } });
1820
1843
  stmts.push({ sql: "UPDATE live_connections SET enabled=0,tested_at=0,revision=revision+1,lease='',lease_until=0,snapshot=NULL,upcoming=NULL,error='Restored content. Test this connection before enabling automatic checks.'" }, { sql: 'DELETE FROM live_oauth_states' });
1821
1844
  if (settingsWrite) stmts.push(settingsWrite);
@@ -1995,6 +2018,15 @@ export class SqlRepository implements ContentRepository {
1995
2018
  await this.ready;
1996
2019
  await this.driver.run('UPDATE settings SET mobile_access = json_patch(mobile_access, ?) WHERE id = 1', [JSON.stringify(patch)]);
1997
2020
  }
2021
+ async getCacheState() {
2022
+ await this.ready;
2023
+ const r = await this.first('SELECT content_version, cache_purged_version FROM settings WHERE id = 1');
2024
+ return { version: Number(r?.content_version ?? 0), purged: Number(r?.cache_purged_version ?? 0) };
2025
+ }
2026
+ async markCachePurged(version: number) {
2027
+ await this.ready;
2028
+ await this.driver.run('UPDATE settings SET cache_purged_version = MAX(cache_purged_version, ?) WHERE id = 1', [version]);
2029
+ }
1998
2030
  private settingsStatement(data: Partial<Settings>): SqlStatement | undefined {
1999
2031
  const columns: Record<string, string> = {
2000
2032
  siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
@@ -39,7 +39,20 @@ export class SqliteRepository extends SqlRepository {
39
39
  }
40
40
  // remote libSQL (Turso, sqld) needs a token; local files don't
41
41
  const authToken = env('SQLITE_AUTH_TOKEN', '');
42
- return new LibsqlDriver(createClient(authToken ? { url, authToken } : { url }));
42
+ const client = createClient(authToken ? { url, authToken } : { url });
43
+ if (url.startsWith('file:')) await tuneLocalFile(client);
44
+ return new LibsqlDriver(client);
43
45
  });
44
46
  }
45
47
  }
48
+
49
+ // Local files only (remote libSQL/Turso manage their own journal): WAL lets
50
+ // page reads continue while a write commits, busy_timeout waits out a
51
+ // concurrent writer (the scheduler, a second process) instead of failing
52
+ // with SQLITE_BUSY, and synchronous=NORMAL is the safe pairing for WAL.
53
+ // File-copy backups must take acms.db, acms.db-wal and acms.db-shm together.
54
+ export async function tuneLocalFile(client: Pick<Client, 'execute'>) {
55
+ await client.execute('PRAGMA journal_mode=WAL');
56
+ await client.execute('PRAGMA busy_timeout=5000');
57
+ await client.execute('PRAGMA synchronous=NORMAL');
58
+ }
@@ -1,4 +1,6 @@
1
+ import { AuthStore } from '../auth/store';
1
2
  import type { FormStore } from '../forms/store';
3
+ import type { BackupStore } from '../backup/store';
2
4
  import type { GuestStore } from '../mobile/guest-store';
3
5
  import type { SavedForm, FormSubmission } from '../forms/schema';
4
6
  import type { LiveStore } from '../live/store';
@@ -50,8 +52,10 @@ export type EventUpdate = Partial<
50
52
  // The single contract both backends implement. The app only ever imports
51
53
  // this — never a concrete adapter.
52
54
  export interface ContentRepository {
55
+ readonly auth: AuthStore;
53
56
  readonly email: EmailStore;
54
57
  readonly forms: FormStore;
58
+ readonly backups: BackupStore;
55
59
  readonly guests: GuestStore;
56
60
  readonly live: LiveStore;
57
61
  readonly push: PushStore;
@@ -258,6 +262,9 @@ export interface ContentRepository {
258
262
  getUser(id: string): Promise<User | null>;
259
263
  createUser(data: { name: string; email: string; role: Role; passHash: string }): Promise<User>;
260
264
  updateUser(id: string, data: { name?: string; email?: string; role?: Role; passHash?: string; bio?: string; avatar?: string; slug?: string }): Promise<void>;
265
+ /** Replace a user's email. `signOut` ends their sessions (the email is their
266
+ * login); `notify` queues an `email-changed` notice to the previous address. */
267
+ changeUserEmail(id: string, email: string, opts: { signOut: boolean; notify: boolean }): Promise<void>;
261
268
  deleteUser(id: string): Promise<void>;
262
269
  getUserAuth(email: string): Promise<{ user: User; passHash: string; sessionVersion: number } | null>;
263
270
 
@@ -300,7 +307,7 @@ export interface ContentRepository {
300
307
  places?: Place[];
301
308
  forms?: SavedForm[];
302
309
  formSubmissions?: FormSubmission[];
303
- }): Promise<void>;
310
+ }, authRecovery?: { issuer: string; subject: string; userId: string }): Promise<void>;
304
311
 
305
312
  // imports (lib/import/apply.ts drives these). writeImported is the
306
313
  // id-preserving bulk write for imported posts/pages: original timestamps
@@ -327,6 +334,10 @@ export interface ContentRepository {
327
334
  updateSettings(data: Partial<Settings>): Promise<void>;
328
335
  /** merges just these App access fields in one statement, so concurrent saves don't undo each other */
329
336
  patchMobileAccess(patch: Partial<Settings['mobileAccess']>): Promise<void>;
337
+ /** public cache: version = bumped by triggers on every visible change; purged = last version cleared from caches */
338
+ getCacheState(): Promise<{ version: number; purged: number }>;
339
+ /** record that caches were cleared up to `version` (never moves backwards) */
340
+ markCachePurged(version: number): Promise<void>;
330
341
  // Design-screen staging: save stages into settings.draftDesign, apply
331
342
  // copies staged → live design fields and clears, discard just clears
332
343
  saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteLogoDark' | 'siteFavicon' | 'siteCover' | 'sectionPages'>> & {
@@ -6,6 +6,8 @@ import { decideOverdue, sendDueReminders } from './booking/service';
6
6
  import { runPushJobs } from './push/service';
7
7
  import { GUEST_IDLE_MS } from './mobile/guest-store';
8
8
  import { pushHeartbeat, pushUrlFor } from './monitoring';
9
+ import { syncCachePurge } from './cache/purge';
10
+ import { runOffsiteBackup } from './backup/offsite';
9
11
 
10
12
  // All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
11
13
  // this heartbeat: monitoring must prove the independent scheduler ran.
@@ -24,6 +26,11 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
24
26
  push: () => runPushJobs(repo, { locals, now: startedAt }),
25
27
  // expired guest sign-in codes and app sessions unused for 90 days
26
28
  guests: () => repo.guests.purge(GUEST_IDLE_MS, startedAt).then(() => 0),
29
+ // clear shared caches after scheduled publishes, and retry a purge that
30
+ // failed after an admin save (Cloudflare API down or rate limited)
31
+ cache: () => syncCachePurge(repo, locals, { force: true }),
32
+ // off-site backup ZIP when one is due (BACKUP_* settings / BACKUPS binding)
33
+ backup: () => runOffsiteBackup(repo, locals),
27
34
  };
28
35
  const results: Record<string, { ok: boolean; count?: number }> = {};
29
36
  for (const [name, run] of Object.entries(jobs)) {
@@ -0,0 +1,163 @@
1
+ import { z } from 'zod';
2
+ import { SectionLayout, type SectionPages } from '../theme-sdk/contract.mjs';
3
+ import type { NavItem, Page, Settings } from './schema';
4
+
5
+ // Theme transfer (acms-theme v1): a theme as you see it on the Design screen,
6
+ // moved between sites or machines as one ZIP:
7
+ // manifest.json format, theme identity, core version, what's included
8
+ // design.json the database half: overrides, brand images, section
9
+ // layouts (keyed by page slug, not ID), optional navigation
10
+ // media/<key> brand and section images, ids and keys verbatim
11
+ // Theme code (src/themes/<slug>/) is compiled into the build, so it never
12
+ // installs at runtime; an import that names a theme this site lacks stages
13
+ // the rest and keeps the current theme.
14
+
15
+ export const THEME_TRANSFER_FORMAT = 'acms-theme';
16
+
17
+ export const ThemeTransferManifest = z.object({
18
+ format: z.literal(THEME_TRANSFER_FORMAT),
19
+ version: z.literal(1),
20
+ exportedAt: z.number().int().nonnegative(),
21
+ coreVersion: z.string().max(50).default(''),
22
+ source: z.object({ title: z.string().max(300).default(''), siteUrl: z.string().max(2000).default('') }).default({}),
23
+ theme: z.object({ slug: z.string().max(50), name: z.string().max(200).default(''), version: z.string().max(50).default(''), project: z.boolean().default(false) }),
24
+ includes: z.array(z.enum(['design', 'media', 'nav'])).max(10),
25
+ }).passthrough();
26
+ export type ThemeTransferManifest = z.infer<typeof ThemeTransferManifest>;
27
+
28
+ const PageKey = z.object({ slug: z.string().max(300), locale: z.string().max(20).default(''), isHome: z.boolean().default(false), title: z.string().max(500).default('') });
29
+ export const ThemeDesignFile = z.object({
30
+ activeTheme: z.string().max(50),
31
+ designOverrides: z.record(z.union([z.string().max(2000), z.number().finite()])).default({}),
32
+ siteLogo: z.string().max(2000).default(''),
33
+ siteLogoDark: z.string().max(2000).default(''),
34
+ siteFavicon: z.string().max(2000).default(''),
35
+ siteCover: z.string().max(2000).default(''),
36
+ layouts: z.array(z.object({ page: PageKey, layout: SectionLayout })).max(500).default([]),
37
+ // link refs (page:/post:/event:/booking:<id>) → the public URL they pointed
38
+ // at, so the importing site can find its own item at the same address
39
+ refs: z.record(z.string().max(200), z.string().max(2000)).default({}),
40
+ navItems: z.array(z.object({ label: z.string().max(200), url: z.string().max(2000), area: z.enum(['header', 'footer', 'social']) })).max(200).optional(),
41
+ });
42
+ export type ThemeDesignFile = z.infer<typeof ThemeDesignFile>;
43
+
44
+ type Pages = z.infer<typeof SectionPages>;
45
+ type PageRow = Pick<Page, 'id' | 'slug' | 'locale' | 'isHome' | 'title'>;
46
+ export interface LinkRef { ref: string; url: string }
47
+
48
+ const walk = (v: unknown, fn: (s: string) => string): unknown => {
49
+ if (typeof v === 'string') return fn(v);
50
+ if (Array.isArray(v)) return v.map((x) => walk(x, fn));
51
+ if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, walk(x, fn)]));
52
+ return v;
53
+ };
54
+ const collectRefs = (v: unknown, out: Set<string>) => {
55
+ if (Array.isArray(v)) v.forEach((x) => collectRefs(x, out));
56
+ else if (v && typeof v === 'object') {
57
+ for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
58
+ if (k === 'ref' && typeof x === 'string') out.add(x); else collectRefs(x, out);
59
+ }
60
+ }
61
+ };
62
+
63
+ /** every uploaded-media key a value points at (/media/<id>/<file>) */
64
+ export function mediaKeysIn(value: unknown): string[] {
65
+ const keys = new Set<string>();
66
+ walk(value, (s) => {
67
+ for (const m of s.matchAll(/\/media\/([\w-]+\/[\w.-]+)/g)) keys.add(m[1]);
68
+ return s;
69
+ });
70
+ return [...keys];
71
+ }
72
+
73
+ /**
74
+ * The database half of an export: the design as the Design screen shows it
75
+ * (draft merged over live). Same-origin absolute media URLs become
76
+ * site-relative so they resolve on the importing site.
77
+ */
78
+ export function buildDesignExport(input: {
79
+ settings: Settings; navItems: Pick<NavItem, 'label' | 'url' | 'area'>[]; pages: PageRow[]; links: LinkRef[]; origin: string; includeNav: boolean;
80
+ }): ThemeDesignFile {
81
+ const { settings: s, origin } = input;
82
+ const local = (v: string) => (origin && v.startsWith(`${origin}/media/`) ? v.slice(origin.length) : v);
83
+ const byId = new Map(input.pages.map((p) => [p.id, p]));
84
+ const layouts = Object.entries(s.sectionPages as Pages).flatMap(([id, layout]) => {
85
+ const p = byId.get(id);
86
+ return p ? [{ page: { slug: p.slug, locale: p.locale ?? '', isHome: Boolean(p.isHome), title: p.title ?? '' }, layout: walk(layout, local) as Pages[string] }] : [];
87
+ });
88
+ const used = new Set<string>();
89
+ collectRefs(layouts, used);
90
+ const urls = new Map(input.links.map((l) => [l.ref, l.url]));
91
+ const refs = Object.fromEntries([...used].flatMap((r) => (urls.has(r) ? [[r, urls.get(r)!]] : [])));
92
+ return {
93
+ activeTheme: s.activeTheme,
94
+ designOverrides: (s.designOverrides ?? {}) as Record<string, string | number>,
95
+ siteLogo: local(s.siteLogo ?? ''), siteLogoDark: local(s.siteLogoDark ?? ''), siteFavicon: local(s.siteFavicon ?? ''), siteCover: local(s.siteCover ?? ''),
96
+ layouts, refs,
97
+ ...(input.includeNav ? { navItems: input.navItems.map(({ label, url, area }) => ({ label, url: local(url), area })) } : {}),
98
+ };
99
+ }
100
+
101
+ export interface DesignImportReport {
102
+ theme: { slug: string; installed: boolean };
103
+ layouts: { matched: string[]; skipped: string[] };
104
+ links: { kept: number; dropped: number };
105
+ nav: number | null;
106
+ }
107
+
108
+ /**
109
+ * The stage patch for an imported design file, against this site's pages,
110
+ * link targets and installed themes. Layouts land on the page with the same
111
+ * slug (and locale, else the home page for the exported home), replacing any
112
+ * staged layout there; pages without a match are reported, never created.
113
+ * Link refs follow the item at the same URL, else fall back to their href.
114
+ */
115
+ export function planDesignImport(input: {
116
+ file: ThemeDesignFile; pages: PageRow[]; links: LinkRef[]; installedThemes: string[];
117
+ stagedPages: Pages; includeNav: boolean;
118
+ }): { patch: Record<string, unknown>; report: DesignImportReport } {
119
+ const { file } = input;
120
+ const installed = input.installedThemes.includes(file.activeTheme);
121
+ const refByUrl = new Map(input.links.map((l) => [l.url, l.ref]));
122
+ let kept = 0, dropped = 0;
123
+ const remap = (v: unknown): unknown => {
124
+ if (Array.isArray(v)) return v.map(remap);
125
+ if (!v || typeof v !== 'object') return v;
126
+ const o = Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, remap(x)]));
127
+ if (typeof o.ref === 'string') {
128
+ const url = file.refs[o.ref];
129
+ const to = url !== undefined ? refByUrl.get(url) : undefined;
130
+ if (to) { o.ref = to; kept++; } else { delete o.ref; dropped++; }
131
+ }
132
+ return o;
133
+ };
134
+ const find = (k: ThemeDesignFile['layouts'][number]['page']) =>
135
+ input.pages.find((p) => p.slug === k.slug && (p.locale ?? '') === k.locale)
136
+ ?? input.pages.find((p) => p.slug === k.slug)
137
+ ?? (k.isHome ? input.pages.find((p) => p.isHome && (p.locale ?? '') === k.locale) ?? input.pages.find((p) => p.isHome) : undefined);
138
+ const sectionPages: Pages = { ...input.stagedPages };
139
+ const matched: string[] = [], skipped: string[] = [];
140
+ for (const { page, layout } of file.layouts) {
141
+ const label = page.isHome ? 'Home' : `/${page.slug}`;
142
+ const target = find(page);
143
+ if (!target) { skipped.push(label); continue; }
144
+ sectionPages[target.id] = { ...layout, sections: layout.sections.map((s) => ({ ...s, data: remap(s.data) as typeof s.data })) };
145
+ matched.push(label);
146
+ }
147
+ const patch: Record<string, unknown> = {
148
+ designOverrides: file.designOverrides,
149
+ siteLogo: file.siteLogo, siteLogoDark: file.siteLogoDark, siteFavicon: file.siteFavicon, siteCover: file.siteCover,
150
+ };
151
+ if (installed) patch.activeTheme = file.activeTheme;
152
+ if (matched.length) patch.sectionPages = sectionPages;
153
+ // an empty list never wipes the receiving site's menus
154
+ const nav = input.includeNav && file.navItems?.length ? file.navItems : null;
155
+ if (nav) patch.navItems = nav.map((n) => ({ ...n, id: `tmp-${crypto.randomUUID()}` }));
156
+ return {
157
+ patch,
158
+ report: { theme: { slug: file.activeTheme, installed }, layouts: { matched, skipped }, links: { kept, dropped }, nav: nav ? nav.length : null },
159
+ };
160
+ }
161
+
162
+ export const themeFileName = (slug: string, now: number) =>
163
+ `${slug.replace(/[^a-z0-9-]/gi, '') || 'theme'}-${new Date(now).toISOString().slice(0, 10)}.acms-theme.zip`;
package/src/lib/theme.ts CHANGED
@@ -194,6 +194,11 @@ export function listThemes(): Theme[] {
194
194
  return themes;
195
195
  }
196
196
 
197
+ /** true for a theme from the project's src/themes/ (its code lives in the site repo) */
198
+ export function isProjectTheme(slug: string): boolean {
199
+ return projectSlugs.has(slug);
200
+ }
201
+
197
202
  export function getTheme(slug: string): Theme {
198
203
  return themes.find((t) => t.slug === slug) ?? themes.find((t) => t.slug === DEFAULT_THEME) ?? themes[0];
199
204
  }
@@ -11,7 +11,7 @@
11
11
  // rejecting stale timestamps.
12
12
  import type { ContentRepository } from './repo/types';
13
13
  import type { Webhook, WebhookDelivery, WebhookEventType } from './schema';
14
- import { isPrivateHost } from './net';
14
+ import { isPrivateHost, resolvesToPrivate } from './net';
15
15
 
16
16
  export const MAX_ATTEMPTS = 4;
17
17
  // minutes until attempt n+1 (after the first immediate try)
@@ -82,7 +82,12 @@ export async function attemptDelivery(repo: ContentRepository, d: WebhookDeliver
82
82
  const claimed = await repo.claimDelivery(d.id, now);
83
83
  if (!claimed) return false;
84
84
  d = claimed;
85
- const fetcher: Fetcher = opts.fetcher ?? ((url, init) => fetch(url, init));
85
+ // the URL passed validateWebhookUrl when saved; re-check what its name
86
+ // resolves to now (dev allows local receivers, as validation does)
87
+ const fetcher: Fetcher = opts.fetcher ?? (async (url, init) => {
88
+ if (!import.meta.env?.DEV && await resolvesToPrivate(new URL(url).hostname)) throw new Error('private host');
89
+ return fetch(url, init);
90
+ });
86
91
  const ts = Math.floor(now / 1000);
87
92
  let code: number | null = null;
88
93
  try {
package/src/middleware.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { defineMiddleware } from 'astro:middleware';
2
+ import type { APIContext, MiddlewareNext } from 'astro';
2
3
  import { I18N } from './lib/i18n';
3
- import { validateSession, SESSION_COOKIE } from './lib/auth';
4
+ import { bindAuthEnvironment, getRequestAuth, shouldResolveClerk, validateRequestSession, withClerkHeaders } from './lib/auth/request';
5
+ import { AUTH } from './lib/auth/config';
4
6
  import { getRepo } from './lib/repo';
5
7
  import { CONTENT } from './lib/content';
6
8
  import { processDueDeliveries, waitUntilFrom } from './lib/webhooks';
@@ -9,6 +11,8 @@ import { decideOverdue, sendDueReminders } from './lib/booking/service';
9
11
  import { PUSH } from './lib/push/config';
10
12
  import { processPushQueue, runPushJobs } from './lib/push/service';
11
13
  import { previewParamRedirect, hasPreviewCookie } from './lib/designPreview';
14
+ import { applyCachePolicy } from './lib/cache/policy';
15
+ import { bindExecutionContext, syncCachePurge } from './lib/cache/purge';
12
16
 
13
17
  // Legacy content-URL redirects, checked only on would-be 404s (below) so they
14
18
  // can never shadow real content. Tags moved from `${base}/tag/` to a
@@ -48,8 +52,16 @@ export function legacyContentRedirect(pathname: string): string | null {
48
52
  // Node target and local dev.
49
53
  let lastPublishCheck = 0;
50
54
 
51
- export const onRequest = defineMiddleware(async (context, next) => {
55
+ // Every response, early redirects included, leaves through the cache policy
56
+ // (lib/cache/policy.ts): public GET pages and the public app API get a
57
+ // shared-cache header; everything personal or staff-only is private.
58
+ export const onRequest = defineMiddleware(async (context, next) =>
59
+ applyCachePolicy(context.request, await handle(context, next), context.url.pathname));
60
+
61
+ async function handle(context: APIContext, next: MiddlewareNext): Promise<Response> {
52
62
  const { pathname } = context.url;
63
+ bindAuthEnvironment(context.request, context.locals);
64
+ bindExecutionContext(context.request, context.locals);
53
65
 
54
66
  // one canonical URL per page: trailing-slash variants 301 to the bare path.
55
67
  // GET pages only — the astro:actions client POSTs to /_actions/<name>/ with
@@ -77,21 +89,31 @@ export const onRequest = defineMiddleware(async (context, next) => {
77
89
  }
78
90
  }
79
91
 
92
+ const auth = AUTH.provider === 'clerk' && shouldResolveClerk(context.request) ? await getRequestAuth(context.request, context.cookies, context.locals) : null;
93
+ if (auth?.headers.get('location') && context.request.method === 'GET' && !pathname.startsWith('/api/')) {
94
+ const headers = new Headers(auth.headers); headers.set('Cache-Control', 'no-store');
95
+ return new Response(null, { status: 307, headers });
96
+ }
97
+
98
+ if (pathname.startsWith('/api/') && (auth?.error === 'unavailable' || auth?.error === 'configuration')) {
99
+ return withClerkHeaders(Response.json({ error: 'Sign-in is temporarily unavailable. Please retry.' }, { status: 503 }), auth, context.request);
100
+ }
101
+
80
102
  // design preview links (?designPreview=1, ?designVersion=, ?preview=<token>,
81
103
  // ?preview=off) become a cookie, then the clean URL — so every link clicked
82
104
  // afterwards stays in preview mode
83
105
  const previewTo = await previewParamRedirect(context.url, context.request.method, context.cookies, async () =>
84
- Boolean(await validateSession(context.cookies.get(SESSION_COOKIE)?.value, context.locals)));
85
- if (previewTo) return context.redirect(previewTo, 302);
106
+ Boolean(await validateRequestSession(context.request, context.cookies, context.locals)));
107
+ if (previewTo) return withClerkHeaders(context.redirect(previewTo, 302), auth, context.request);
86
108
 
87
- if (pathname.startsWith('/admin') && pathname !== '/admin/login') {
88
- const token = context.cookies.get(SESSION_COOKIE)?.value;
89
- if (!(await validateSession(token, context.locals))) {
109
+ if (pathname.startsWith('/admin') && pathname !== '/admin/login' && pathname !== '/admin/logout') {
110
+ if (auth?.error === 'configuration' || auth?.error === 'unavailable') return new Response('Sign-in is temporarily unavailable. Check the authentication configuration or try again later.', { status: 503, headers: { 'Cache-Control': 'no-store' } });
111
+ if (!(await validateRequestSession(context.request, context.cookies, context.locals))) {
90
112
  // editor-app sign-in comes back here after login (see login.astro's next)
91
113
  if (pathname === '/admin/app-auth' && context.request.method === 'GET') {
92
- return context.redirect(`/admin/login?next=${encodeURIComponent(pathname + context.url.search)}`);
114
+ return withClerkHeaders(context.redirect(`/admin/login?next=${encodeURIComponent(pathname + context.url.search)}`), auth, context.request);
93
115
  }
94
- return context.redirect('/admin/login');
116
+ return withClerkHeaders(context.redirect('/admin/login'), auth, context.request);
95
117
  }
96
118
  }
97
119
 
@@ -121,12 +143,16 @@ export const onRequest = defineMiddleware(async (context, next) => {
121
143
  const push = runPushJobs(repo, { locals: context.locals, fallbackOrigin: context.url.origin, now })
122
144
  .catch((e) => console.error('acms push: scheduled jobs failed', e));
123
145
  if (wait) wait(push);
146
+ // a purge that failed or was throttled after an earlier write
147
+ // (Node: detached — the process outlives the request)
148
+ const purge = syncCachePurge(repo, context.locals).catch((e) => console.error('acms cache: purge failed', e));
149
+ if (wait) wait(purge);
124
150
  } catch {
125
151
  // never let the publish sweep break a request
126
152
  }
127
153
  }
128
154
 
129
- const response = await next();
155
+ const response = withClerkHeaders(await next(), auth, context.request);
130
156
 
131
157
  // Database writes enqueue publication/media notifications atomically.
132
158
  // Flush after mutations (including Astro actions), once their transaction
@@ -142,6 +168,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
142
168
  .catch((e) => console.error('acms push: queue flush failed', e));
143
169
  if (wait) wait(push);
144
170
  }
171
+ // content changed → clear shared caches (the scheduler retries failures)
172
+ const purge = getRepo(context.locals).then(repo => syncCachePurge(repo, context.locals))
173
+ .catch((e) => console.error('acms cache: purge failed', e));
174
+ if (wait) wait(purge);
145
175
  }
146
176
 
147
177
  // a preview render must never be cached where a visitor could get it
@@ -169,7 +199,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
169
199
  }
170
200
 
171
201
  return response;
172
- });
202
+ }
173
203
 
174
204
  let redirectCache: { at: number; map: Map<string, { toPath: string; code: 301 | 302 }> } | null = null;
175
205
 
@@ -3,7 +3,7 @@ import ThemeSections from '../components/ThemeSections.astro';
3
3
  import Site from '../layouts/Site.astro';
4
4
  import WidgetRenderer from '../components/WidgetRenderer.astro';
5
5
  import { getRepo } from '../lib/repo';
6
- import { validateSession, SESSION_COOKIE } from '../lib/auth';
6
+ import { validateRequestSession } from '../lib/auth/request';
7
7
  import { expandDynamicBlocks } from '../lib/dynamicBlocks';
8
8
  import { highlightCodeHtml } from '../lib/highlightCode';
9
9
  import { resolveDesign } from '../lib/theme';
@@ -18,7 +18,7 @@ const rawPage = slug ? await repo.getPageBySlug(slug) : null;
18
18
  const page = rawPage ? localizeDoc(rawPage, locale) : null;
19
19
 
20
20
  // Drafts are visible only to a logged-in admin (doubles as draft preview).
21
- const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
21
+ const isAdmin = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
22
22
  if (!page || (page.status !== 'published' && !isAdmin)) {
23
23
  return new Response(null, { status: 404, statusText: 'Not found' });
24
24
  }
@@ -37,6 +37,7 @@ const PageTemplate = (page.template && settings.sectionPages?.[page.id]?.mode !=
37
37
  ---
38
38
 
39
39
  <Site
40
+ staffPreview={!!isAdmin && page.status !== 'published'}
40
41
  title={page.seoTitle || page.title}
41
42
  exactTitle={Boolean(page.seoTitle)}
42
43
  description={page.seoDesc || undefined}
@@ -5,10 +5,16 @@ import { sha256 } from '../../lib/email/config';
5
5
  import { dispatchEmail, kickEmail } from '../../lib/email/service';
6
6
  import { waitUntilFrom } from '../../lib/webhooks';
7
7
  import { SESSION_COOKIE } from '../../lib/auth';
8
+ import { AUTH } from '../../lib/auth/config';
8
9
  import '../../styles/admin.css';
9
10
  const action=Astro.params.action||'';
10
11
  if(!['invite','reset','forgot'].includes(action))return new Response('Not found',{status:404});
11
12
  Astro.response.headers.set('Cache-Control','no-store');
13
+ // Clerk handles invitations and recovery; local links are never redeemed.
14
+ if(AUTH.provider==='clerk'){
15
+ if(Astro.request.method!=='GET'&&Astro.request.method!=='HEAD')return new Response('Account access is managed by the sign-in provider.',{status:403,headers:{'Cache-Control':'no-store'}});
16
+ return new Response(null,{status:303,headers:{Location:'/admin/login','Cache-Control':'no-store','Referrer-Policy':'no-referrer'}});
17
+ }
12
18
  Astro.response.headers.set('Referrer-Policy','no-referrer');
13
19
  Astro.response.headers.set('X-Robots-Tag','noindex');
14
20
  const repo=await getRepo(Astro.locals);