@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 +21 -4
- package/dist/{chunk-AYV6KYDP.js → chunk-NSAZCP4I.js} +20 -1
- package/dist/content/index.js +1 -1
- package/dist/server/index.d.ts +84 -18
- package/dist/server/index.js +708 -117
- package/dist/studio/index.js +25 -5
- package/package.json +1 -1
- package/sql/bootstrap.sql +313 -6
- package/sql/migrations/20260829032027_cms_security_and_sync_contracts.sql +390 -0
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,
|
|
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
|
-
| `
|
|
79
|
-
| `
|
|
80
|
-
| `
|
|
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.
|
|
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
|
});
|
package/dist/content/index.js
CHANGED
package/dist/server/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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,
|
|
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: '
|
|
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 };
|