@velastack/cms 0.1.0 → 0.2.0
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 +1 -1
- package/dist/backend/auth/editors.d.ts +51 -0
- package/dist/backend/auth/editors.js +157 -0
- package/dist/backend/auth/local-editors.d.ts +22 -0
- package/dist/backend/auth/local-editors.js +44 -0
- package/dist/backend/auth/scrypt.d.ts +20 -0
- package/dist/backend/auth/scrypt.js +80 -0
- package/dist/backend/factory.d.ts +25 -0
- package/dist/backend/factory.js +202 -0
- package/dist/backend/http.d.ts +34 -0
- package/dist/backend/http.js +79 -0
- package/dist/backend/index.d.ts +45 -0
- package/dist/backend/index.js +39 -0
- package/dist/backend/router.d.ts +52 -0
- package/dist/backend/router.js +57 -0
- package/dist/backend/routes/context.d.ts +46 -0
- package/dist/backend/routes/context.js +30 -0
- package/dist/backend/routes/docs.d.ts +2 -0
- package/dist/backend/routes/docs.js +150 -0
- package/dist/backend/routes/login.d.ts +19 -0
- package/dist/backend/routes/login.js +155 -0
- package/dist/backend/routes/media.d.ts +22 -0
- package/dist/backend/routes/media.js +119 -0
- package/dist/backend/routes/pages.d.ts +19 -0
- package/dist/backend/routes/pages.js +221 -0
- package/dist/backend/routes/release.d.ts +10 -0
- package/dist/backend/routes/release.js +147 -0
- package/dist/backend/routes/site.d.ts +2 -0
- package/dist/backend/routes/site.js +40 -0
- package/dist/backend/routes/table.d.ts +11 -0
- package/dist/backend/routes/table.js +72 -0
- package/dist/backend/routes/test-reset.d.ts +15 -0
- package/dist/backend/routes/test-reset.js +112 -0
- package/dist/backend/routes/user.d.ts +14 -0
- package/dist/backend/routes/user.js +21 -0
- package/dist/backend/storage.d.ts +18 -0
- package/dist/backend/storage.js +45 -0
- package/dist/backend/store/cache.d.ts +34 -0
- package/dist/backend/store/cache.js +38 -0
- package/dist/backend/store/db.d.ts +10 -0
- package/dist/backend/store/db.js +29 -0
- package/dist/backend/store/migrate.d.ts +10 -0
- package/dist/backend/store/migrate.js +30 -0
- package/dist/backend/store/migrations/0001-init.d.ts +1 -0
- package/dist/backend/store/migrations/0001-init.js +129 -0
- package/dist/backend/store/migrations/0002-site.d.ts +1 -0
- package/dist/backend/store/migrations/0002-site.js +86 -0
- package/dist/backend/store/migrations/0003-editors.d.ts +1 -0
- package/dist/backend/store/migrations/0003-editors.js +44 -0
- package/dist/backend/store/migrations/index.d.ts +15 -0
- package/dist/backend/store/migrations/index.js +18 -0
- package/dist/backend/store/queries.d.ts +67 -0
- package/dist/backend/store/queries.js +1207 -0
- package/dist/backend/store/sqlite.d.ts +22 -0
- package/dist/backend/store/sqlite.js +29 -0
- package/dist/backend/testing/fixtures.d.ts +39 -0
- package/dist/backend/testing/fixtures.js +61 -0
- package/dist/backend/testing/harness.d.ts +35 -0
- package/dist/backend/testing/harness.js +149 -0
- package/dist/backend/types.d.ts +102 -0
- package/dist/cms.d.ts +3 -3
- package/dist/cms.js +1 -1
- package/dist/components/admin-bar/publish-dialog.svelte +1 -1
- package/dist/components/cms/cms-image-editable.svelte +1 -1
- package/dist/components/cms/cms-store.svelte.d.ts +3 -68
- package/dist/components/cms/cms-store.svelte.js +25 -10
- package/dist/components/cms/locale-merge.d.ts +1 -1
- package/dist/components/cms/locale-merge.js +1 -1
- package/dist/components/cms/scope.d.ts +1 -1
- package/dist/core/page-entry.d.ts +43 -0
- package/dist/core/page-entry.js +21 -0
- package/dist/{components/cms → core}/path.d.ts +13 -0
- package/dist/{components/cms → core}/path.js +36 -0
- package/dist/core/wire.d.ts +182 -0
- package/dist/core/wire.js +1 -0
- package/dist/{srv → server}/api-adapter.js +2 -2
- package/dist/{srv → server}/load-cms.js +1 -1
- package/dist/{srv → server}/mock-adapter.d.ts +3 -24
- package/dist/{srv → server}/mock-adapter.js +5 -22
- package/dist/{srv → server}/types.d.ts +2 -6
- package/dist/server/types.js +1 -0
- package/package.json +13 -4
- package/plugin/dist/index.js +7 -0
- package/plugin/dist/index.js.map +1 -1
- package/plugin/dist/media.d.ts +16 -7
- package/plugin/dist/media.js +23 -10
- package/plugin/dist/media.js.map +1 -1
- /package/dist/{srv → backend}/types.js +0 -0
- /package/dist/{srv → server}/README.md +0 -0
- /package/dist/{srv → server}/api-adapter.d.ts +0 -0
- /package/dist/{srv → server}/cms.d.ts +0 -0
- /package/dist/{srv → server}/cms.js +0 -0
- /package/dist/{srv → server}/index.d.ts +0 -0
- /package/dist/{srv → server}/index.js +0 -0
- /package/dist/{srv → server}/load-cms.d.ts +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface SqliteStatement<Params extends unknown[] = unknown[], Row = unknown> {
|
|
2
|
+
run(...params: Params): {
|
|
3
|
+
changes: number;
|
|
4
|
+
lastInsertRowid: number | bigint;
|
|
5
|
+
};
|
|
6
|
+
get(...params: Params): Row | undefined;
|
|
7
|
+
all(...params: Params): Row[];
|
|
8
|
+
}
|
|
9
|
+
export interface SqliteDb {
|
|
10
|
+
prepare<Params extends unknown[] = unknown[], Row = unknown>(sql: string): SqliteStatement<Params, Row>;
|
|
11
|
+
exec(sql: string): void;
|
|
12
|
+
transaction<Fn extends (...args: never[]) => unknown>(fn: Fn): Fn;
|
|
13
|
+
pragma(source: string): unknown;
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|
|
16
|
+
export type SqliteDriver = new (path: string) => SqliteDb;
|
|
17
|
+
/**
|
|
18
|
+
* Resolve `better-sqlite3` at call time. It is an optional peer dependency, so
|
|
19
|
+
* a project that only uses the components and the SSR read path never installs
|
|
20
|
+
* it — and never compiles a native module.
|
|
21
|
+
*/
|
|
22
|
+
export declare const loadDriver: () => SqliteDriver;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A structural view of the slice of `better-sqlite3` this backend uses.
|
|
3
|
+
*
|
|
4
|
+
* Declaring the shape here rather than importing `@types/better-sqlite3` has
|
|
5
|
+
* three payoffs. The driver stays out of the emitted `dist/backend/*.d.ts`, so
|
|
6
|
+
* a consumer type-checking against `@velastack/cms/backend` needs neither the
|
|
7
|
+
* package nor its types. `createRequire` is opaque to bundlers, which is what a
|
|
8
|
+
* native `.node` binding needs anyway. And a host can hand in an already-open
|
|
9
|
+
* connection through `db`, which is also how tests get an isolated `:memory:`
|
|
10
|
+
* database per file.
|
|
11
|
+
*
|
|
12
|
+
* The generic `prepare<Params, Row>` signature matches how the store already
|
|
13
|
+
* calls it, so the call sites are unchanged from the original.
|
|
14
|
+
*/
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
/**
|
|
17
|
+
* Resolve `better-sqlite3` at call time. It is an optional peer dependency, so
|
|
18
|
+
* a project that only uses the components and the SSR read path never installs
|
|
19
|
+
* it — and never compiles a native module.
|
|
20
|
+
*/
|
|
21
|
+
export const loadDriver = () => {
|
|
22
|
+
try {
|
|
23
|
+
return createRequire(import.meta.url)('better-sqlite3');
|
|
24
|
+
}
|
|
25
|
+
catch (cause) {
|
|
26
|
+
throw new Error("@velastack/cms/backend requires 'better-sqlite3', which is an optional peer " +
|
|
27
|
+
'dependency. Install it with `npm i better-sqlite3`.', { cause });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type CmsBackend } from '../factory.js';
|
|
2
|
+
import { type CmsTestClient } from './harness.js';
|
|
3
|
+
import type { CmsBackendOptions, CmsEditor } from '../types.js';
|
|
4
|
+
/** Cheap on purpose: fast enough to run per test, real enough to exercise the
|
|
5
|
+
* same code path as production. */
|
|
6
|
+
export declare const TEST_SCRYPT: {
|
|
7
|
+
N: number;
|
|
8
|
+
r: number;
|
|
9
|
+
p: number;
|
|
10
|
+
};
|
|
11
|
+
export type TestFixture = {
|
|
12
|
+
backend: CmsBackend;
|
|
13
|
+
/** Unauthenticated client for the default project. */
|
|
14
|
+
anon: CmsTestClient;
|
|
15
|
+
/** Signed in as alice, who can reach `p1` and `p_alice`. */
|
|
16
|
+
alice: CmsTestClient;
|
|
17
|
+
/** Signed in as bob, who can reach `p1` and `p_bob`. */
|
|
18
|
+
bob: CmsTestClient;
|
|
19
|
+
users: {
|
|
20
|
+
alice: CmsEditor;
|
|
21
|
+
bob: CmsEditor;
|
|
22
|
+
};
|
|
23
|
+
/** A client for another project, optionally carrying a session. */
|
|
24
|
+
at: (projectId: string, as?: CmsTestClient) => CmsTestClient;
|
|
25
|
+
uploadDir: string;
|
|
26
|
+
};
|
|
27
|
+
export type TestFixtureOptions = {
|
|
28
|
+
/** Defaults to `p1`. */
|
|
29
|
+
projectId?: string;
|
|
30
|
+
/** Mount path. Defaults to the multi-tenant shape so params are exercised. */
|
|
31
|
+
basePath?: (projectId: string) => string;
|
|
32
|
+
backend?: Partial<CmsBackendOptions>;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Two accounts sharing one project and each holding a private one — enough to
|
|
36
|
+
* exercise collaboration (same publish queue, conflicting drafts) and
|
|
37
|
+
* cross-project denial without a third.
|
|
38
|
+
*/
|
|
39
|
+
export declare const createTestFixture: (options?: TestFixtureOptions) => Promise<TestFixture>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup for tests that drive the backend over its HTTP surface.
|
|
3
|
+
*
|
|
4
|
+
* Each call builds an isolated `':memory:'` backend with two editors, so test
|
|
5
|
+
* files hold no shared state and run in parallel. Password hashing is dialled
|
|
6
|
+
* right down — these tests are about routing and content, and real scrypt
|
|
7
|
+
* parameters would dominate the run.
|
|
8
|
+
*
|
|
9
|
+
* Exported from `@velastack/cms/backend` because a host integrating its own
|
|
10
|
+
* auth adapter wants the same harness for its own tests.
|
|
11
|
+
*/
|
|
12
|
+
import { mkdtempSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { createCmsBackend } from '../factory.js';
|
|
16
|
+
import { localEditors } from '../auth/local-editors.js';
|
|
17
|
+
import { createCmsTestClient } from './harness.js';
|
|
18
|
+
/** Cheap on purpose: fast enough to run per test, real enough to exercise the
|
|
19
|
+
* same code path as production. */
|
|
20
|
+
export const TEST_SCRYPT = { N: 1024, r: 8, p: 1 };
|
|
21
|
+
/**
|
|
22
|
+
* Two accounts sharing one project and each holding a private one — enough to
|
|
23
|
+
* exercise collaboration (same publish queue, conflicting drafts) and
|
|
24
|
+
* cross-project denial without a third.
|
|
25
|
+
*/
|
|
26
|
+
export const createTestFixture = async (options = {}) => {
|
|
27
|
+
const projectId = options.projectId ?? 'p1';
|
|
28
|
+
const basePath = options.basePath ?? ((p) => `/v1/projects/${p}/cms`);
|
|
29
|
+
const uploadDir = mkdtempSync(join(tmpdir(), 'vela-cms-test-'));
|
|
30
|
+
const backend = createCmsBackend({
|
|
31
|
+
dbPath: ':memory:',
|
|
32
|
+
uploadDir,
|
|
33
|
+
testReset: true,
|
|
34
|
+
auth: localEditors({ scrypt: TEST_SCRYPT }),
|
|
35
|
+
resolveProject: (event) => event.params.project_id ?? projectId,
|
|
36
|
+
...options.backend
|
|
37
|
+
});
|
|
38
|
+
const alice = await backend.editors.create({
|
|
39
|
+
email: 'alice@example.com',
|
|
40
|
+
password: 'password',
|
|
41
|
+
name: 'Alice',
|
|
42
|
+
projects: [projectId, 'p_alice']
|
|
43
|
+
});
|
|
44
|
+
const bob = await backend.editors.create({
|
|
45
|
+
email: 'bob@example.com',
|
|
46
|
+
password: 'password',
|
|
47
|
+
name: 'Bob',
|
|
48
|
+
projects: [projectId, 'p_bob']
|
|
49
|
+
});
|
|
50
|
+
const clientFor = (p) => createCmsTestClient(backend, { basePath: basePath(p), params: { project_id: p } });
|
|
51
|
+
const anon = clientFor(projectId);
|
|
52
|
+
return {
|
|
53
|
+
backend,
|
|
54
|
+
anon,
|
|
55
|
+
alice: anon.as(`cms_session=${backend.editors.createSession(alice.id).token}`),
|
|
56
|
+
bob: anon.as(`cms_session=${backend.editors.createSession(bob.id).token}`),
|
|
57
|
+
users: { alice, bob },
|
|
58
|
+
at: (p, as) => (as ?? anon).at(basePath(p), { project_id: p }),
|
|
59
|
+
uploadDir
|
|
60
|
+
};
|
|
61
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CmsBackend } from '../factory.js';
|
|
2
|
+
export type TestResponse = {
|
|
3
|
+
status: number;
|
|
4
|
+
headers: Headers;
|
|
5
|
+
text: string;
|
|
6
|
+
json<T = unknown>(): T;
|
|
7
|
+
};
|
|
8
|
+
export type RequestInitLike = {
|
|
9
|
+
body?: unknown;
|
|
10
|
+
formData?: FormData;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
cookie?: string;
|
|
13
|
+
};
|
|
14
|
+
export type CmsTestClient = {
|
|
15
|
+
get(path: string, init?: RequestInitLike): Promise<TestResponse>;
|
|
16
|
+
post(path: string, init?: RequestInitLike): Promise<TestResponse>;
|
|
17
|
+
del(path: string, init?: RequestInitLike): Promise<TestResponse>;
|
|
18
|
+
request(method: string, path: string, init?: RequestInitLike): Promise<TestResponse>;
|
|
19
|
+
/** A client that sends this cookie on every request. */
|
|
20
|
+
as(cookie: string): CmsTestClient;
|
|
21
|
+
/** A client mounted at a different base path / route params. */
|
|
22
|
+
at(basePath: string, params?: Record<string, string>): CmsTestClient;
|
|
23
|
+
/** The cookie jar accumulated from `set-cookie` responses. */
|
|
24
|
+
cookies: Map<string, string>;
|
|
25
|
+
};
|
|
26
|
+
export type TestClientOptions = {
|
|
27
|
+
/** Where the backend is mounted, e.g. `/v1/projects/p1/cms`. */
|
|
28
|
+
basePath: string;
|
|
29
|
+
/** Route params other than the rest parameter, e.g. `{ project_id: 'p1' }`. */
|
|
30
|
+
params?: Record<string, string>;
|
|
31
|
+
/** Name of the rest parameter. Must match the backend's `restParam`. */
|
|
32
|
+
restParam?: string;
|
|
33
|
+
origin?: string;
|
|
34
|
+
};
|
|
35
|
+
export declare const createCmsTestClient: (backend: CmsBackend, options: TestClientOptions) => CmsTestClient;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const parseCookieHeader = (header) => {
|
|
2
|
+
const out = new Map();
|
|
3
|
+
if (!header)
|
|
4
|
+
return out;
|
|
5
|
+
for (const part of header.split(';')) {
|
|
6
|
+
const eq = part.indexOf('=');
|
|
7
|
+
if (eq === -1)
|
|
8
|
+
continue;
|
|
9
|
+
out.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
};
|
|
13
|
+
const serializeCookie = (name, value, opts) => {
|
|
14
|
+
const bits = [`${name}=${encodeURIComponent(value)}`];
|
|
15
|
+
if (opts.path)
|
|
16
|
+
bits.push(`Path=${opts.path}`);
|
|
17
|
+
if (typeof opts.maxAge === 'number')
|
|
18
|
+
bits.push(`Max-Age=${opts.maxAge}`);
|
|
19
|
+
if (opts.httpOnly)
|
|
20
|
+
bits.push('HttpOnly');
|
|
21
|
+
if (opts.secure)
|
|
22
|
+
bits.push('Secure');
|
|
23
|
+
if (opts.partitioned)
|
|
24
|
+
bits.push('Partitioned');
|
|
25
|
+
if (opts.sameSite) {
|
|
26
|
+
const v = String(opts.sameSite);
|
|
27
|
+
bits.push(`SameSite=${v.charAt(0).toUpperCase()}${v.slice(1)}`);
|
|
28
|
+
}
|
|
29
|
+
return bits.join('; ');
|
|
30
|
+
};
|
|
31
|
+
export const createCmsTestClient = (backend, options) => {
|
|
32
|
+
const restParam = options.restParam ?? 'path';
|
|
33
|
+
const origin = options.origin ?? 'http://localhost';
|
|
34
|
+
const basePath = options.basePath.replace(/\/$/, '');
|
|
35
|
+
const jar = new Map();
|
|
36
|
+
const run = async (method, path, init = {}) => {
|
|
37
|
+
const url = new URL(`${basePath}${path.startsWith('/') ? path : `/${path}`}`, origin);
|
|
38
|
+
const rest = url.pathname.slice(basePath.length).replace(/^\//, '');
|
|
39
|
+
const headers = new Headers(init.headers ?? {});
|
|
40
|
+
const cookieHeader = init.cookie ?? (jar.size > 0 ? [...jar].map(([k, v]) => `${k}=${v}`).join('; ') : undefined);
|
|
41
|
+
if (cookieHeader)
|
|
42
|
+
headers.set('cookie', cookieHeader);
|
|
43
|
+
let body;
|
|
44
|
+
if (init.formData) {
|
|
45
|
+
body = init.formData;
|
|
46
|
+
}
|
|
47
|
+
else if (init.body !== undefined) {
|
|
48
|
+
body = JSON.stringify(init.body);
|
|
49
|
+
if (!headers.has('content-type'))
|
|
50
|
+
headers.set('content-type', 'application/json');
|
|
51
|
+
}
|
|
52
|
+
const request = new Request(url, { method, headers, body });
|
|
53
|
+
const requestCookies = parseCookieHeader(headers.get('cookie') ?? undefined);
|
|
54
|
+
// Mirrors SvelteKit: lowercase keys, duplicates throw, set-cookie throws.
|
|
55
|
+
const accumulated = {};
|
|
56
|
+
const setHeaders = (newHeaders) => {
|
|
57
|
+
for (const key in newHeaders) {
|
|
58
|
+
const lower = key.toLowerCase();
|
|
59
|
+
if (lower === 'set-cookie') {
|
|
60
|
+
throw new Error('Use `event.cookies.set` instead of `event.setHeaders` to set cookies');
|
|
61
|
+
}
|
|
62
|
+
if (lower in accumulated) {
|
|
63
|
+
throw new Error(`"${key}" header is already set`);
|
|
64
|
+
}
|
|
65
|
+
accumulated[lower] = newHeaders[key];
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const pending = [];
|
|
69
|
+
const cookies = {
|
|
70
|
+
get: (name) => requestCookies.get(name),
|
|
71
|
+
getAll: () => [...requestCookies].map(([name, value]) => ({ name, value })),
|
|
72
|
+
set: (name, value, opts) => {
|
|
73
|
+
requestCookies.set(name, value);
|
|
74
|
+
pending.push(serializeCookie(name, value, opts));
|
|
75
|
+
},
|
|
76
|
+
delete: (name, opts) => {
|
|
77
|
+
requestCookies.delete(name);
|
|
78
|
+
pending.push(serializeCookie(name, '', { ...opts, maxAge: 0 }));
|
|
79
|
+
},
|
|
80
|
+
serialize: serializeCookie
|
|
81
|
+
};
|
|
82
|
+
const event = {
|
|
83
|
+
request,
|
|
84
|
+
url,
|
|
85
|
+
params: { ...(options.params ?? {}), [restParam]: rest },
|
|
86
|
+
locals: {},
|
|
87
|
+
cookies,
|
|
88
|
+
setHeaders,
|
|
89
|
+
route: { id: `${basePath}/[...${restParam}]` },
|
|
90
|
+
isDataRequest: false,
|
|
91
|
+
isSubRequest: false,
|
|
92
|
+
isRemoteRequest: false,
|
|
93
|
+
platform: undefined,
|
|
94
|
+
getClientAddress: () => '127.0.0.1',
|
|
95
|
+
fetch: globalThis.fetch
|
|
96
|
+
};
|
|
97
|
+
const response = await backend.handle(event);
|
|
98
|
+
// SvelteKit applies accumulated headers over the response, then appends
|
|
99
|
+
// cookies. Order matters: setHeaders wins.
|
|
100
|
+
for (const [k, v] of Object.entries(accumulated))
|
|
101
|
+
response.headers.set(k, v);
|
|
102
|
+
for (const c of pending)
|
|
103
|
+
response.headers.append('set-cookie', c);
|
|
104
|
+
for (const c of pending) {
|
|
105
|
+
const [pair] = c.split(';');
|
|
106
|
+
const eq = pair.indexOf('=');
|
|
107
|
+
const name = pair.slice(0, eq);
|
|
108
|
+
const value = decodeURIComponent(pair.slice(eq + 1));
|
|
109
|
+
if (/Max-Age=0/.test(c))
|
|
110
|
+
jar.delete(name);
|
|
111
|
+
else
|
|
112
|
+
jar.set(name, value);
|
|
113
|
+
}
|
|
114
|
+
const text = await response.text();
|
|
115
|
+
return {
|
|
116
|
+
status: response.status,
|
|
117
|
+
headers: response.headers,
|
|
118
|
+
text,
|
|
119
|
+
json: () => JSON.parse(text)
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
const client = {
|
|
123
|
+
request: run,
|
|
124
|
+
get: (path, init) => run('GET', path, init),
|
|
125
|
+
post: (path, init) => run('POST', path, init),
|
|
126
|
+
del: (path, init) => run('DELETE', path, init),
|
|
127
|
+
as: (cookie) => {
|
|
128
|
+
const scoped = createCmsTestClient(backend, options);
|
|
129
|
+
const [name, ...rest] = cookie.split('=');
|
|
130
|
+
scoped.cookies.set(name.trim(), rest.join('='));
|
|
131
|
+
return scoped;
|
|
132
|
+
},
|
|
133
|
+
// Carries the cookie jar across, so `client.as(session).at(otherProject)`
|
|
134
|
+
// is the same browser visiting a different mount — which is exactly the
|
|
135
|
+
// cross-project authorization case.
|
|
136
|
+
at: (nextBasePath, params) => {
|
|
137
|
+
const scoped = createCmsTestClient(backend, {
|
|
138
|
+
...options,
|
|
139
|
+
basePath: nextBasePath,
|
|
140
|
+
params
|
|
141
|
+
});
|
|
142
|
+
for (const [k, v] of jar)
|
|
143
|
+
scoped.cookies.set(k, v);
|
|
144
|
+
return scoped;
|
|
145
|
+
},
|
|
146
|
+
cookies: jar
|
|
147
|
+
};
|
|
148
|
+
return client;
|
|
149
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { RequestEvent } from '@sveltejs/kit';
|
|
2
|
+
import type { SqliteDb } from './store/sqlite.js';
|
|
3
|
+
/** An authenticated content editor. */
|
|
4
|
+
export type CmsEditor = {
|
|
5
|
+
id: string;
|
|
6
|
+
email: string;
|
|
7
|
+
name: string;
|
|
8
|
+
};
|
|
9
|
+
/** What a successful login produces: the editor, and the raw session token to
|
|
10
|
+
* put in the cookie. */
|
|
11
|
+
export type CmsSessionGrant = {
|
|
12
|
+
user: CmsEditor;
|
|
13
|
+
token: string;
|
|
14
|
+
expiresAt: Date;
|
|
15
|
+
};
|
|
16
|
+
/** Handed to every adapter method so an implementation can reach the database
|
|
17
|
+
* and know which tenant the request is for without closing over the factory. */
|
|
18
|
+
export type CmsAuthContext = {
|
|
19
|
+
db: SqliteDb;
|
|
20
|
+
projectId: string;
|
|
21
|
+
cookieName: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The seam between the CMS and whatever owns identity.
|
|
25
|
+
*
|
|
26
|
+
* `localEditors()` is the built-in implementation, backed by the CMS's own
|
|
27
|
+
* `cms_editors` table. A host that already has accounts — velastack's
|
|
28
|
+
* PocketBase users, say — supplies its own and the router is none the wiser.
|
|
29
|
+
*/
|
|
30
|
+
export type CmsAuthAdapter = {
|
|
31
|
+
/** Resolve the request's editor, or `null` for anonymous. Must not throw on
|
|
32
|
+
* a malformed or stale cookie: anonymous is a valid outcome, not an error. */
|
|
33
|
+
resolve(event: RequestEvent, ctx: CmsAuthContext): Promise<CmsEditor | null>;
|
|
34
|
+
/** May this editor act on this project? A `false` here is a 403 before any
|
|
35
|
+
* handler runs. */
|
|
36
|
+
authorize(user: CmsEditor, projectId: string, ctx: CmsAuthContext): Promise<boolean>;
|
|
37
|
+
/** Backs the built-in login page. Omit it and `/iframe/login` 404s, leaving
|
|
38
|
+
* login entirely to the host. */
|
|
39
|
+
login?(email: string, password: string, ctx: CmsAuthContext): Promise<CmsSessionGrant | null>;
|
|
40
|
+
/** Invalidate server-side session state on logout. The cookie is cleared
|
|
41
|
+
* regardless. */
|
|
42
|
+
logout?(event: RequestEvent, ctx: CmsAuthContext): Promise<void>;
|
|
43
|
+
};
|
|
44
|
+
export type CmsCookieOptions = {
|
|
45
|
+
name?: string;
|
|
46
|
+
path?: string;
|
|
47
|
+
sameSite?: 'lax' | 'strict' | 'none';
|
|
48
|
+
secure?: boolean;
|
|
49
|
+
/** CHIPS. Pairs with `sameSite: 'none'` to survive third-party cookie
|
|
50
|
+
* deprecation in the embedded-iframe login flow. */
|
|
51
|
+
partitioned?: boolean;
|
|
52
|
+
maxAge?: number;
|
|
53
|
+
};
|
|
54
|
+
export type CmsCorsOptions = {
|
|
55
|
+
/** Called with the request's `Origin`. Return true to allow it. */
|
|
56
|
+
origin: (origin: string, event: RequestEvent) => boolean;
|
|
57
|
+
methods?: string[];
|
|
58
|
+
headers?: string[];
|
|
59
|
+
credentials?: boolean;
|
|
60
|
+
};
|
|
61
|
+
export type CmsBackendOptions = {
|
|
62
|
+
/** SQLite file path. `':memory:'` is supported. Ignored when `db` is given.
|
|
63
|
+
* Defaults to `$VELA_DATA_DIR/cms.sqlite`, else `./data/cms.sqlite`. */
|
|
64
|
+
dbPath?: string;
|
|
65
|
+
/** An already-open connection. Migrations still run against it. */
|
|
66
|
+
db?: SqliteDb;
|
|
67
|
+
/** Where uploaded bytes are written. Defaults to a sibling `uploads/`
|
|
68
|
+
* directory next to the database. */
|
|
69
|
+
uploadDir?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Base URL for media, written into `media_items.url` and embedded in
|
|
72
|
+
* content. Defaults to a root-relative `/uploads`, which keeps the origin
|
|
73
|
+
* out of stored content. Pass an absolute base when the CMS is on a
|
|
74
|
+
* different origin than the site — or to preserve an existing wire format.
|
|
75
|
+
*/
|
|
76
|
+
mediaBaseUrl?: string | ((event: RequestEvent) => string);
|
|
77
|
+
/** Which tenant this request is for. Returning `null` is a 404. Defaults to
|
|
78
|
+
* the constant `'default'`, which is the single-tenant case. */
|
|
79
|
+
resolveProject?: (event: RequestEvent) => string | null;
|
|
80
|
+
/** Name of the catch-all rest parameter the handlers are mounted under. */
|
|
81
|
+
restParam?: string;
|
|
82
|
+
/** Identity and authorization. Defaults to `localEditors()`. */
|
|
83
|
+
auth?: CmsAuthAdapter;
|
|
84
|
+
cookie?: CmsCookieOptions;
|
|
85
|
+
/**
|
|
86
|
+
* Cross-origin access. `false` (the default) emits no CORS headers, which is
|
|
87
|
+
* correct for a same-origin mount.
|
|
88
|
+
*
|
|
89
|
+
* Enabling this is a security decision, not a convenience one: the session
|
|
90
|
+
* cookie is credentialed and the JSON mutation endpoints are not covered by
|
|
91
|
+
* SvelteKit's CSRF check (it only guards form content types). An `origin`
|
|
92
|
+
* predicate that returns `true` unconditionally lets any site on the
|
|
93
|
+
* internet drive those endpoints with a signed-in editor's cookie. Allowlist
|
|
94
|
+
* the origins you actually serve.
|
|
95
|
+
*/
|
|
96
|
+
cors?: false | CmsCorsOptions;
|
|
97
|
+
/** `frame-ancestors` for the login pages. Defaults to `'self'`; the
|
|
98
|
+
* cross-origin admin bar needs the embedding site (or `*`). */
|
|
99
|
+
frameAncestors?: string;
|
|
100
|
+
/** Expose `POST /__test_reset__`. Never enable in production. */
|
|
101
|
+
testReset?: boolean;
|
|
102
|
+
};
|
package/dist/cms.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare const loadCms: (event: import("@sveltejs/kit").ServerLoadEvent, { locale }: {
|
|
2
2
|
locale: string;
|
|
3
|
-
}) => Promise<import("./
|
|
4
|
-
(): Promise<import("./
|
|
5
|
-
<R extends import("$app/types").RouteId>(routeId: R): Promise<import("./
|
|
3
|
+
}) => Promise<import("./server/index.js").LoadCmsResult>, generateEntries: {
|
|
4
|
+
(): Promise<import("./server/index.js").CmsEntry<import("$app/types").RouteParams<import("$app/types").RouteId>>[]>;
|
|
5
|
+
<R extends import("$app/types").RouteId>(routeId: R): Promise<import("./server/index.js").CmsEntry<import("$app/types").RouteParams<R>>[]>;
|
|
6
6
|
};
|
package/dist/cms.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createCms, apiAdapter } from './
|
|
1
|
+
import { createCms, apiAdapter } from './server/index.js';
|
|
2
2
|
import { locales } from '../locales/data.js';
|
|
3
3
|
export const { load: loadCms, generateEntries } = createCms({
|
|
4
4
|
adapter: apiAdapter({ endpoint: 'https://velastack.dev/v1/projects/velastack-cms/cms' }),
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { page } from '$app/state';
|
|
3
3
|
import type { ReleaseItem } from '../cms/cms-store.svelte.js';
|
|
4
4
|
import type { CmsPayload } from '../cms/scope.js';
|
|
5
|
-
import { diffPaths, type Tree } from '
|
|
5
|
+
import { diffPaths, type Tree } from '../../core/path.js';
|
|
6
6
|
import { resolveRouteOnlyParams, resolveRouteUrl } from './resolve-route.js';
|
|
7
7
|
import { Badge, type BadgeVariant } from './ui/badge/index.js';
|
|
8
8
|
import { Button } from './ui/button/index.js';
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { tick as svelteTick } from 'svelte';
|
|
3
3
|
import { page } from '$app/state';
|
|
4
4
|
import { cmsStore, type CmsScopeRef, type MediaItem } from './cms-store.svelte.js';
|
|
5
|
-
import { get as pathGet } from '
|
|
5
|
+
import { get as pathGet } from '../../core/path.js';
|
|
6
6
|
import type { CmsImageValue } from './cms-image.svelte';
|
|
7
7
|
import { Button } from '../admin-bar/ui/button/index.js';
|
|
8
8
|
import { Input } from '../admin-bar/ui/input/index.js';
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { type Tree } from '
|
|
1
|
+
import { type Tree } from '../../core/path.js';
|
|
2
|
+
import type { MediaItem, OpenRelease, PageDeleteOutcome, ReleaseItem } from '../../core/wire.js';
|
|
2
3
|
import type { CmsEntry, CmsPagePointer, CmsScopeEntry } from './scope.js';
|
|
4
|
+
export type { MediaItem, OpenRelease, PageDeleteOutcome, ReleaseItem };
|
|
3
5
|
export type CmsScopeRef = {
|
|
4
6
|
scopeId: string;
|
|
5
7
|
routeId: string;
|
|
@@ -10,72 +12,6 @@ type DraftBucket = {
|
|
|
10
12
|
locale: string;
|
|
11
13
|
tree: Tree;
|
|
12
14
|
};
|
|
13
|
-
/**
|
|
14
|
-
* Outcome of a page-delete release item: omit for hard-delete (404 after
|
|
15
|
-
* publish), set to `gone` for permanent removal (410), or `redirect` to a
|
|
16
|
-
* target URL for a permanent redirect (308). The CMS server keeps the
|
|
17
|
-
* outcome on the published tombstone after the release is published.
|
|
18
|
-
*/
|
|
19
|
-
export type PageDeleteOutcome = {
|
|
20
|
-
kind: 'gone';
|
|
21
|
-
} | {
|
|
22
|
-
kind: 'redirect';
|
|
23
|
-
to: string;
|
|
24
|
-
};
|
|
25
|
-
export type ReleaseItem = {
|
|
26
|
-
kind: 'page';
|
|
27
|
-
routeId: string;
|
|
28
|
-
params: Record<string, string>;
|
|
29
|
-
locale: string;
|
|
30
|
-
tree: Tree;
|
|
31
|
-
addedAt: string;
|
|
32
|
-
} | {
|
|
33
|
-
kind: 'layout';
|
|
34
|
-
routeId: string;
|
|
35
|
-
locale: string;
|
|
36
|
-
tree: Tree;
|
|
37
|
-
addedAt: string;
|
|
38
|
-
} | {
|
|
39
|
-
kind: 'site';
|
|
40
|
-
tree: Tree;
|
|
41
|
-
addedAt: string;
|
|
42
|
-
} | {
|
|
43
|
-
kind: 'page-delete';
|
|
44
|
-
routeId: string;
|
|
45
|
-
params: Record<string, string>;
|
|
46
|
-
locale: string;
|
|
47
|
-
outcome?: PageDeleteOutcome;
|
|
48
|
-
addedAt: string;
|
|
49
|
-
};
|
|
50
|
-
export type OpenRelease = {
|
|
51
|
-
userId: string;
|
|
52
|
-
name?: string;
|
|
53
|
-
createdAt: string;
|
|
54
|
-
preview_key: string;
|
|
55
|
-
items: ReleaseItem[];
|
|
56
|
-
};
|
|
57
|
-
/**
|
|
58
|
-
* Wire shape of a media library item, mirroring the server's `MediaItem`.
|
|
59
|
-
* `url` is the public path served by the static handler (e.g. `/uploads/abc.png`).
|
|
60
|
-
*/
|
|
61
|
-
export type MediaItem = {
|
|
62
|
-
id: string;
|
|
63
|
-
filename: string;
|
|
64
|
-
originalName: string;
|
|
65
|
-
mime: string;
|
|
66
|
-
size: number;
|
|
67
|
-
url: string;
|
|
68
|
-
uploadedAt: string;
|
|
69
|
-
uploadedBy: string;
|
|
70
|
-
};
|
|
71
|
-
/**
|
|
72
|
-
* Origin where uploaded media files are served. The backend writes files to
|
|
73
|
-
* `static/uploads/` and returns relative paths like `/uploads/<slug>.png`; the
|
|
74
|
-
* frontend lives on a different origin in dev, so we prefix each URL here at
|
|
75
|
-
* the wire boundary so display components and persisted field values both get
|
|
76
|
-
* a fully-qualified URL.
|
|
77
|
-
*/
|
|
78
|
-
export declare const MEDIA_URL_PREFIX = "https://velastack.dev";
|
|
79
15
|
declare class CmsStore {
|
|
80
16
|
isEditing: boolean;
|
|
81
17
|
drafts: Record<string, DraftBucket>;
|
|
@@ -291,4 +227,3 @@ declare class Cms {
|
|
|
291
227
|
get site(): Record<string, unknown>;
|
|
292
228
|
}
|
|
293
229
|
export declare const cms: Cms;
|
|
294
|
-
export {};
|
|
@@ -27,20 +27,35 @@
|
|
|
27
27
|
* Locales panel passes an explicit locale to inspect other locales'
|
|
28
28
|
* working-copy state without switching the page.
|
|
29
29
|
*/
|
|
30
|
+
import { browser } from '$app/environment';
|
|
30
31
|
import { page } from '$app/state';
|
|
31
32
|
import { mergeLocaleDocs, mergeLocaleEntries } from './locale-merge.js';
|
|
32
33
|
import { composeKey } from './overlay-sync.js';
|
|
33
|
-
import { get, has, mergeTree, set } from '
|
|
34
|
+
import { get, has, mergeTree, set } from '../../core/path.js';
|
|
34
35
|
const scopeKindFromId = (scopeId) => scopeId.startsWith('layout:') ? 'layout' : 'page';
|
|
35
36
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* the
|
|
40
|
-
* a
|
|
37
|
+
* Fully qualify a media URL against the CMS endpoint.
|
|
38
|
+
*
|
|
39
|
+
* The backend may return a root-relative path (`/uploads/<slug>.png`) — that is
|
|
40
|
+
* the default for a same-origin mount, and it keeps the origin out of persisted
|
|
41
|
+
* content. When the CMS lives on a different origin than the site, resolving
|
|
42
|
+
* that path against `location.href` would point at the site, so it is resolved
|
|
43
|
+
* against the endpoint instead. Absolute URLs are returned untouched.
|
|
44
|
+
*
|
|
45
|
+
* This used to be a hardcoded `https://velastack.dev`, which was silently wrong
|
|
46
|
+
* for every other deployment.
|
|
41
47
|
*/
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
const resolveMediaItem = (item, endpoint) => {
|
|
49
|
+
if (!item.url.startsWith('/'))
|
|
50
|
+
return item;
|
|
51
|
+
try {
|
|
52
|
+
const base = new URL(endpoint, browser ? location.href : 'http://localhost');
|
|
53
|
+
return { ...item, url: new URL(item.url, base).toString() };
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return item;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
44
59
|
const treeHasAnyLeaf = (tree) => {
|
|
45
60
|
for (const v of Object.values(tree)) {
|
|
46
61
|
if (v === undefined)
|
|
@@ -475,7 +490,7 @@ class CmsStore {
|
|
|
475
490
|
if (typeof item?.url !== 'string') {
|
|
476
491
|
throw new Error('Upload response missing url');
|
|
477
492
|
}
|
|
478
|
-
return resolveMediaItem(item);
|
|
493
|
+
return resolveMediaItem(item, endpoint);
|
|
479
494
|
}
|
|
480
495
|
/** Back-compat wrapper around `uploadMedia` that returns just the URL. */
|
|
481
496
|
async uploadImage(endpoint, file) {
|
|
@@ -491,7 +506,7 @@ class CmsStore {
|
|
|
491
506
|
if (!res.ok)
|
|
492
507
|
throw new Error(`List failed (${res.status})`);
|
|
493
508
|
const data = (await res.json());
|
|
494
|
-
return { ...data, items: data.items.map(resolveMediaItem) };
|
|
509
|
+
return { ...data, items: data.items.map((i) => resolveMediaItem(i, endpoint)) };
|
|
495
510
|
}
|
|
496
511
|
async deleteMedia(endpoint, id) {
|
|
497
512
|
const res = await fetch(`${endpoint}/media/${id}`, {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* precedence (requested wins outright) before passing trees in.
|
|
13
13
|
*/
|
|
14
14
|
import type { CmsEntry } from './scope.js';
|
|
15
|
-
import { type Tree } from '
|
|
15
|
+
import { type Tree } from '../../core/path.js';
|
|
16
16
|
/**
|
|
17
17
|
* Merge requested-locale doc trees over default-locale doc trees, per scope.
|
|
18
18
|
*
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Tombstones are not handled here — callers should resolve tombstone
|
|
12
12
|
* precedence (requested wins outright) before passing trees in.
|
|
13
13
|
*/
|
|
14
|
-
import { mergeTree } from '
|
|
14
|
+
import { mergeTree } from '../../core/path.js';
|
|
15
15
|
/**
|
|
16
16
|
* Merge requested-locale doc trees over default-locale doc trees, per scope.
|
|
17
17
|
*
|
|
@@ -48,7 +48,7 @@ export type CmsPagePointer = {
|
|
|
48
48
|
params: Record<string, string>;
|
|
49
49
|
};
|
|
50
50
|
/**
|
|
51
|
-
* One publishable page-kind entry. Mirrored from `
|
|
51
|
+
* One publishable page-kind entry. Mirrored from `server/types.ts` so client-only
|
|
52
52
|
* code (`<CmsEntries>`, the merged `cms` view) can read entries without
|
|
53
53
|
* importing server modules. The server type re-exports this one.
|
|
54
54
|
*
|