@orion-studios/cms 0.5.6 → 0.5.7

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.
package/README.md CHANGED
@@ -41,7 +41,14 @@ Requires Next.js (App Router), React 19, Zod 3. Supabase only in supabase mode.
41
41
  import { createCmsRoutes } from '@orion-studios/cms/server'
42
42
  import { registry } from '@/cms/registry'
43
43
  export const { GET, POST, PATCH, DELETE } = createCmsRoutes({
44
- registry, syncToken: process.env.CMS_SYNC_TOKEN,
44
+ registry,
45
+ syncToken: process.env.CMS_SYNC_TOKEN,
46
+ cronToken: process.env.CRON_SECRET,
47
+ previewSecret: process.env.CMS_PREVIEW_SECRET,
48
+ previewKeyId: process.env.CMS_PREVIEW_KEY_ID,
49
+ analyticsSecret: process.env.CMS_ANALYTICS_HASH_SECRET,
50
+ autoReplyHashSecret: process.env.CMS_AUTO_REPLY_HASH_SECRET,
51
+ projectRef: process.env.CMS_EXPECTED_SUPABASE_PROJECT_REF,
45
52
  })
46
53
 
47
54
  // src/app/studio/page.tsx — the admin
@@ -75,10 +82,20 @@ own role, never delete yourself.
75
82
  | Variable | Where | Purpose |
76
83
  | --- | --- | --- |
77
84
  | `NEXT_PUBLIC_SUPABASE_URL` | local + Vercel | project URL (also selects supabase mode) |
78
- | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | local + Vercel | public reads (RLS: published content only) |
79
- | `SUPABASE_SERVICE_ROLE_KEY` | local + Vercel | server-only; powers the API layer |
80
- | `DATABASE_URL` | local + Vercel | Postgres for bootstrap (use the transaction pooler on Vercel) |
85
+ | `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` | local + Vercel | public reads (RLS: published content only) |
86
+ | `SUPABASE_SECRET_KEY` | local + Vercel | server-only API access; the legacy service-role alias remains supported |
87
+ | `CMS_EXPECTED_SUPABASE_PROJECT_REF` | local + Vercel | binds the project URL, server credential, previews, and operator database target |
88
+ | `CMS_DATABASE_URL` | local operator only | direct or port 5432 session connection for bootstrap and migrations |
89
+ | `CMS_EXPECTED_DATABASE_ROLE` | local operator only | dedicated CMS migration role with no `app` schema access |
90
+ | `CMS_EXPECTED_DATABASE_NAME` | local operator only | exact database name required by the target guard |
91
+ | `CMS_DATABASE_CONFIRMATION` | local operator only | exact redacted target-receipt hash required before database writes |
92
+ | `CMS_PREVIEW_SECRET` | local + Vercel | signs short-lived, revocable preview grants |
93
+ | `CMS_PREVIEW_KEY_ID` | local + Vercel | non-secret identifier bound into preview grants |
94
+ | `CMS_PREVIEW_PREVIOUS_SECRET`, `CMS_PREVIEW_PREVIOUS_KEY_ID`, `CMS_PREVIEW_PREVIOUS_VALID_UNTIL` | local + Vercel | optional bounded preview-key rotation overlap |
95
+ | `CMS_ANALYTICS_HASH_SECRET` | local + Vercel | hashes analytics session and visitor identifiers |
96
+ | `CMS_AUTO_REPLY_HASH_SECRET` | local + Vercel | hashes form auto-reply rate-limit keys |
81
97
  | `CMS_SYNC_TOKEN` | local + Vercel | authorizes `POST /api/cms/sync` |
98
+ | `CRON_SECRET` | local + Vercel | authorizes scheduled publication only |
82
99
  | `CMS_STATIC=true` | optional | static mode (no CMS) |
83
100
  | `CMS_MEMORY=true` | optional | force memory mode |
84
101
 
@@ -1,5 +1,21 @@
1
1
  // src/content/index.ts
2
2
  import { createClient } from "@supabase/supabase-js";
3
+
4
+ // src/supabase-keys.ts
5
+ var isServerSupabaseKey = (key) => {
6
+ if (key.startsWith("sb_secret_")) return true;
7
+ const payload = key.split(".")[1];
8
+ if (!payload) return false;
9
+ try {
10
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
11
+ const decoded = JSON.parse(atob(normalized));
12
+ return decoded.role === "service_role";
13
+ } catch {
14
+ return false;
15
+ }
16
+ };
17
+
18
+ // src/content/index.ts
3
19
  var CONTENT_CACHE_TAG = "orion-content";
4
20
  var toPublicPage = (row) => ({
5
21
  id: String(row.id),
@@ -11,12 +27,15 @@ var toPublicPage = (row) => ({
11
27
  });
12
28
  function createContentClient(options = {}) {
13
29
  const supabaseUrl = options.supabaseUrl || process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || "";
14
- const anonKey = options.anonKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_PUBLISHABLE_KEY || "";
30
+ const anonKey = options.anonKey || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_ANON_KEY || "";
15
31
  if (!supabaseUrl || !anonKey) {
16
32
  throw new Error(
17
33
  "Orion CMS: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY (or NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) must be set."
18
34
  );
19
35
  }
36
+ if (isServerSupabaseKey(anonKey)) {
37
+ throw new Error("Orion CMS: a Supabase server key cannot be used for public content reads.");
38
+ }
20
39
  const client = createClient(supabaseUrl, anonKey, {
21
40
  auth: { persistSession: false, autoRefreshToken: false }
22
41
  });
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CONTENT_CACHE_TAG,
3
3
  createContentClient
4
- } from "../chunk-AYV6KYDP.js";
4
+ } from "../chunk-NSAZCP4I.js";
5
5
  export {
6
6
  CONTENT_CACHE_TAG,
7
7
  createContentClient
@@ -52,8 +52,10 @@ type CmsRoutesOptions = {
52
52
  allowedOrigins?: string[];
53
53
  /** Rate limiter for public submissions. Defaults to in-memory (5/min/IP). */
54
54
  rateLimitStore?: RateLimitStore | null;
55
- /** Token accepted by POST /sync and POST /cron/* in addition to admin bearer auth. */
55
+ /** Token accepted only by POST /sync in addition to admin bearer auth. */
56
56
  syncToken?: string;
57
+ /** Token accepted only by POST or GET /cron/publish-due. */
58
+ cronToken?: string;
57
59
  /** Extra domains never flagged as email typos. */
58
60
  knownGoodEmailDomains?: string[];
59
61
  /**
@@ -63,8 +65,20 @@ type CmsRoutesOptions = {
63
65
  sendEmail?: EmailSender | null;
64
66
  /** Site name used in notification emails. */
65
67
  siteName?: string;
66
- /** Secret for draft preview tokens. Defaults to the service-role key. */
68
+ /** Purpose-specific secret for draft preview grants. */
67
69
  previewSecret?: string;
70
+ /** Stable identifier for the active preview signing key. */
71
+ previewKeyId?: string;
72
+ /** Optional prior preview key accepted only through the bounded overlap. */
73
+ previewPreviousSecret?: string;
74
+ previewPreviousKeyId?: string;
75
+ previewPreviousValidUntil?: number;
76
+ /** Stable Supabase project reference bound into preview grants. */
77
+ projectRef?: string;
78
+ /** Purpose-specific secret for analytics visitor and session hashing. */
79
+ analyticsSecret?: string;
80
+ /** Purpose-specific secret for auto-reply recipient hashing. */
81
+ autoReplyHashSecret?: string;
68
82
  /** Cloudflare Turnstile secret — when set, public submits must include a valid `_turnstileToken`. */
69
83
  turnstileSecret?: string;
70
84
  /** Max upload size in bytes (default 15 MB). */
@@ -120,10 +134,35 @@ declare function createCmsRoutes(options: CmsRoutesOptions): {
120
134
  };
121
135
 
122
136
  declare const PREVIEW_TOKEN_TTL_MS: number;
137
+ declare const PREVIEW_TOKEN_MAX_USES = 8;
123
138
  declare const PREVIEW_SESSION_COOKIE = "orion_preview_session";
124
- declare function createPreviewToken(pageId: string, secret: string, ttlMs?: number): string;
125
- /** Returns the page id when the token is valid and unexpired, else null. */
126
- declare function verifyPreviewToken(token: string, secret: string): string | null;
139
+ type PreviewSigningKey = {
140
+ id: string;
141
+ secret: string;
142
+ };
143
+ type PreviewKeyRing = {
144
+ active: PreviewSigningKey;
145
+ previous?: PreviewSigningKey & {
146
+ acceptUntil: number;
147
+ };
148
+ };
149
+ type PreviewGrantClaims = {
150
+ version: 1;
151
+ audience: 'cms-preview';
152
+ operation: 'read-draft';
153
+ keyId: string;
154
+ grantId: string;
155
+ pageId: string;
156
+ userId: string;
157
+ projectRef: string;
158
+ issuedAt: number;
159
+ expiresAt: number;
160
+ };
161
+ declare function createPreviewToken(grant: Omit<PreviewGrantClaims, 'version' | 'audience' | 'operation' | 'keyId' | 'issuedAt' | 'expiresAt'>, keys: PreviewKeyRing | string, ttlMs?: number): string;
162
+ /** Returns bound claims when the token is valid and unexpired, else null. */
163
+ declare function verifyPreviewGrantToken(token: string, keys: PreviewKeyRing | string, now?: number): PreviewGrantClaims | null;
164
+ /** Compatibility helper for callers that only need the signed page binding. */
165
+ declare function verifyPreviewToken(token: string, keys: PreviewKeyRing | string): string | null;
127
166
  type PreviewPage = {
128
167
  id: string;
129
168
  slug: string;
@@ -136,7 +175,7 @@ type PreviewPage = {
136
175
  * Site-side helper: verifies a preview token and loads the page's DRAFT
137
176
  * layout with the given (service or memory) client.
138
177
  */
139
- declare function getPreviewPage(client: SupabaseClient, token: string, secret: string): Promise<PreviewPage | null>;
178
+ declare function getPreviewPage(client: SupabaseClient, token: string, keys: PreviewKeyRing | string, expectedProjectRef: string): Promise<PreviewPage | null>;
140
179
 
141
180
  /**
142
181
  * Pure analytics aggregation: raw event rows in, the full dashboard payload
@@ -301,9 +340,11 @@ declare function geoFrom(request: Request): {
301
340
  * access token), verified via auth.getUser(). Stateless: no cookie plumbing.
302
341
  */
303
342
  type CmsEnv = {
343
+ expectedProjectRef: string;
304
344
  supabaseUrl: string;
305
345
  serviceRoleKey: string;
306
346
  };
347
+ declare function validateCmsEnv(env: CmsEnv): CmsEnv;
307
348
  declare function readCmsEnv(): CmsEnv;
308
349
  declare function getServiceClient(env?: CmsEnv): SupabaseClient;
309
350
  /** Test seam: inject a fake client. */
@@ -333,14 +374,6 @@ declare function can(user: CmsUser | null, action: CmsAction): boolean;
333
374
  */
334
375
  declare function isStructuralChange(previous: PageLayout, next: PageLayout): boolean;
335
376
 
336
- /**
337
- * Content-as-code sync: the single implementation used by both the
338
- * POST /api/cms/sync route and local `npm run sync` scripts.
339
- *
340
- * Contract (proven on Currin): create missing pages, refresh metadata always,
341
- * write layouts only where the page is not builder-owned, upsert globals and
342
- * forms. Idempotent.
343
- */
344
377
  type SyncPageInput = {
345
378
  slug: string;
346
379
  path?: string;
@@ -358,6 +391,11 @@ type SyncFormInput = {
358
391
  config?: Record<string, unknown>;
359
392
  successMessage?: string;
360
393
  };
394
+ type SyncRedirectInput = {
395
+ fromPath: string;
396
+ toPath: string;
397
+ permanent?: boolean;
398
+ };
361
399
  type SyncMediaInput = {
362
400
  storagePath: string;
363
401
  filename?: string;
@@ -373,8 +411,27 @@ type SyncInput = {
373
411
  globals?: SyncGlobalInput[];
374
412
  forms?: SyncFormInput[];
375
413
  media?: SyncMediaInput[];
414
+ redirects?: SyncRedirectInput[];
415
+ };
416
+ type SyncOperation = {
417
+ kind: 'page' | 'global' | 'form' | 'media' | 'redirect';
418
+ key: string;
419
+ action: 'create' | 'update' | 'noop' | 'conflict' | 'blocked-delete';
420
+ };
421
+ type SyncPlan = {
422
+ mode: 'dry-run';
423
+ manifestHash: string;
424
+ targetReceiptHash: string;
425
+ operations: SyncOperation[];
426
+ skipped: Array<{
427
+ slug: string;
428
+ issues: unknown;
429
+ }>;
376
430
  };
377
431
  type SyncResult = {
432
+ mode: 'apply';
433
+ manifestHash: string;
434
+ targetReceiptHash: string;
378
435
  synced: string[];
379
436
  skipped: Array<{
380
437
  slug: string;
@@ -383,14 +440,23 @@ type SyncResult = {
383
440
  globals: number;
384
441
  forms: number;
385
442
  media: number;
386
- /** Non-page resources that failed to write (globals/forms). */
387
443
  failed: Array<{
388
- kind: 'global' | 'form';
444
+ kind: SyncOperation['kind'];
389
445
  key: string;
390
446
  error: string;
391
447
  }>;
448
+ conflicts: SyncOperation[];
449
+ };
450
+ type SyncOptions = {
451
+ mode?: 'dry-run';
452
+ expectedProjectRef?: string;
453
+ } | {
454
+ mode: 'apply';
455
+ expectedManifestHash: string;
456
+ expectedProjectRef?: string;
457
+ expectedTargetReceiptHash: string;
392
458
  };
393
- declare function runContentSync(client: SupabaseClient, registry: BlockRegistry, input: SyncInput): Promise<SyncResult>;
459
+ declare function runContentSync(client: SupabaseClient, registry: BlockRegistry, input: SyncInput, options?: SyncOptions): Promise<SyncPlan | SyncResult>;
394
460
 
395
461
  /**
396
462
  * In-memory CMS backend for local development and end-to-end tests.
@@ -423,4 +489,4 @@ declare function createMemoryCms(): MemoryCms;
423
489
  /** Process-wide singleton for Next.js dev servers (module state survives HMR via globalThis). */
424
490
  declare function getMemoryCms(): MemoryCms;
425
491
 
426
- export { type AnalyticsEventType, type AnalyticsKpis, type AnalyticsSummary, type CmsAction, type CmsEnv, type CmsRole, type CmsRoutesOptions, type CmsUser, type EmailMessage, type EmailSender, type IncomingEvent, MEMORY_DEV_TOKEN, type MemoryCms, PREVIEW_SESSION_COOKIE, PREVIEW_TOKEN_TTL_MS, type PreviewPage, type ResendSenderOptions, type StoredEvent, type SyncFormInput, type SyncGlobalInput, type SyncInput, type SyncMediaInput, type SyncPageInput, type SyncResult, aggregateAnalytics, can, createCmsRoutes, createDurableRateLimitStore, createMemoryCms, createPreviewToken, createResendSender, deviceFrom, formatSubmissionText, geoFrom, getMemoryCms, getPreviewPage, getServiceClient, isBotRequest, isStructuralChange, notifySubmission, parseEventBatch, readCmsEnv, resolveUser, runContentSync, sessionKeyFor, setServiceClientForTesting, verifyPreviewToken, visitorKeyFor };
492
+ export { type AnalyticsEventType, type AnalyticsKpis, type AnalyticsSummary, type CmsAction, type CmsEnv, type CmsRole, type CmsRoutesOptions, type CmsUser, type EmailMessage, type EmailSender, type IncomingEvent, MEMORY_DEV_TOKEN, type MemoryCms, PREVIEW_SESSION_COOKIE, PREVIEW_TOKEN_MAX_USES, PREVIEW_TOKEN_TTL_MS, type PreviewGrantClaims, type PreviewPage, type ResendSenderOptions, type StoredEvent, type SyncFormInput, type SyncGlobalInput, type SyncInput, type SyncMediaInput, type SyncOperation, type SyncOptions, type SyncPageInput, type SyncPlan, type SyncResult, aggregateAnalytics, can, createCmsRoutes, createDurableRateLimitStore, createMemoryCms, createPreviewToken, createResendSender, deviceFrom, formatSubmissionText, geoFrom, getMemoryCms, getPreviewPage, getServiceClient, isBotRequest, isStructuralChange, notifySubmission, parseEventBatch, readCmsEnv, resolveUser, runContentSync, sessionKeyFor, setServiceClientForTesting, validateCmsEnv, verifyPreviewGrantToken, verifyPreviewToken, visitorKeyFor };