@velastack/cms 0.3.0 → 0.4.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 CHANGED
@@ -161,7 +161,7 @@ That's it — the plugin handles scope and the loader handles data fetching.
161
161
 
162
162
  ## Production builds: prerender + media download
163
163
 
164
- `loadCms` is **server-only** — call it from `+layout.server.ts` (or `+page.server.ts`), never from a `+page.svelte` or universal `+page.ts`. Calling it in the browser throws. Editing/preview happens through `cmsStore` from `@velastack/cms`, which is gated by your `cms_session` cookie and opaque preview/version keys.
164
+ `loadCms` is **server-only** — call it from `+layout.server.ts` (or `+page.server.ts`), never from a `+page.svelte` or universal `+page.ts`. Calling it in the browser throws. Editing/preview happens through `cmsStore` from `@velastack/cms`, which is gated by your `cms_session` cookie (scoped to the CMS mount path, so sites sharing an origin hold independent sessions) and opaque preview/version keys.
165
165
 
166
166
  The recommended production shape is **prerender by default**:
167
167
 
@@ -1,4 +1,27 @@
1
1
  import { createEditorStore } from './editors.js';
2
+ /**
3
+ * Every value sent under `name` in a `Cookie` header, in the order the browser
4
+ * listed them. SvelteKit's `cookies.get` collapses duplicates to the first,
5
+ * which is right for resolving a session and wrong for ending them all.
6
+ */
7
+ const cookieValues = (header, name) => {
8
+ if (!header)
9
+ return [];
10
+ const out = [];
11
+ for (const part of header.split(';')) {
12
+ const eq = part.indexOf('=');
13
+ if (eq === -1 || part.slice(0, eq).trim() !== name)
14
+ continue;
15
+ const raw = part.slice(eq + 1).trim();
16
+ try {
17
+ out.push(decodeURIComponent(raw));
18
+ }
19
+ catch {
20
+ out.push(raw);
21
+ }
22
+ }
23
+ return out;
24
+ };
2
25
  /**
3
26
  * The adapter caches one editor store per database. `resolve` runs on every
4
27
  * request, and re-preparing a dozen statements each time would be wasteful; the
@@ -36,9 +59,15 @@ export const localEditors = (options = {}) => {
36
59
  return { user, token, expiresAt };
37
60
  },
38
61
  async logout(event, ctx) {
39
- const token = event.cookies.get(ctx.cookieName);
40
- if (token)
41
- storeFor(ctx).destroySession(token);
62
+ // A browser can hold a root-scoped cookie from before 0.3.1 next to
63
+ // the mount-scoped one; logging out ends both sessions.
64
+ const store = storeFor(ctx);
65
+ for (const token of cookieValues(event.request.headers.get('cookie'), ctx.cookieName)) {
66
+ store.destroySession(token);
67
+ }
68
+ },
69
+ async discard(grant, ctx) {
70
+ storeFor(ctx).destroySession(grant.token);
42
71
  }
43
72
  };
44
73
  };
@@ -79,13 +79,14 @@ export const createCmsBackend = (options = {}) => {
79
79
  * Cookie attributes. `sameSite: 'none'` is required for the cross-origin
80
80
  * iframe login and wrong for a same-origin mount on plain http, so the
81
81
  * default follows whether CORS is configured rather than being hardcoded
82
- * one way.
82
+ * one way. `path` is decided per request in `handle`: the mount path unless
83
+ * the host set one, so projects sharing an origin hold independent sessions.
83
84
  */
84
- const cookieOptionsFor = (event) => {
85
+ const cookieOptionsFor = (event, path) => {
85
86
  const crossOrigin = cors !== false;
86
87
  const isHttps = event.url.protocol === 'https:';
87
88
  return {
88
- path: options.cookie?.path ?? '/',
89
+ path,
89
90
  httpOnly: true,
90
91
  sameSite: options.cookie?.sameSite ?? (crossOrigin ? 'none' : 'lax'),
91
92
  secure: options.cookie?.secure ?? (crossOrigin ? true : isHttps),
@@ -140,28 +141,37 @@ export const createCmsBackend = (options = {}) => {
140
141
  const projectId = resolveProject(event);
141
142
  if (projectId === null)
142
143
  return applyCors(event, notFound());
144
+ // Pure string work, so it can precede auth. A root mount yields ''.
145
+ const mountPath = mountPathFor(event, restPath);
146
+ const cookiePath = options.cookie?.path ?? (mountPath || '/');
147
+ // Versions before 0.3.1 set the cookie at '/'. While the default is in
148
+ // use, login and logout expire that one too — otherwise a stale root
149
+ // cookie keeps answering for this mount alongside the scoped one.
150
+ const legacyPath = options.cookie?.path === undefined && cookiePath !== '/' ? '/' : null;
143
151
  const authCtx = { db, projectId, cookieName };
144
- // Resolve, then authorize, then dispatch. Both checks happen before any
145
- // handler body runs, which is what keeps a cross-project write from
146
- // touching the database at all.
147
- let user = null;
152
+ // Resolve, then authorize, on every route class, before any handler body
153
+ // runs. A session with no grant on this project is anonymous on this
154
+ // project: `required` is a 403, `public` serves published content, and
155
+ // only the login page is told the session exists so it can explain the
156
+ // form. That is what keeps a cross-project write from touching the
157
+ // database at all, and what lets one browser edit several sites.
158
+ let sessionUser = null;
148
159
  try {
149
- user = await auth.resolve(event, authCtx);
160
+ sessionUser = await auth.resolve(event, authCtx);
150
161
  }
151
162
  catch {
152
- user = null;
153
- }
154
- if (user && route.auth !== 'exempt' && !(await auth.authorize(user, projectId, authCtx))) {
155
- return applyCors(event, new Response(null, { status: 403 }));
163
+ sessionUser = null;
156
164
  }
165
+ const user = sessionUser && (await auth.authorize(sessionUser, projectId, authCtx)) ? sessionUser : null;
157
166
  if (route.auth === 'required' && !user) {
158
167
  return applyCors(event, new Response(null, { status: 403 }));
159
168
  }
160
- const mountPath = mountPathFor(event, restPath);
169
+ const expire = (path) => event.cookies.set(cookieName, '', { ...cookieOptionsFor(event, path), maxAge: 0 });
161
170
  const ctx = {
162
171
  event,
163
172
  projectId,
164
173
  user,
174
+ sessionUser,
165
175
  params,
166
176
  store,
167
177
  cache,
@@ -174,14 +184,17 @@ export const createCmsBackend = (options = {}) => {
174
184
  mountPath,
175
185
  frameAncestors,
176
186
  setSessionCookie: (token, expiresAt) => {
177
- const opts = cookieOptionsFor(event);
178
187
  event.cookies.set(cookieName, token, {
179
- ...opts,
188
+ ...cookieOptionsFor(event, cookiePath),
180
189
  maxAge: Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000))
181
190
  });
191
+ if (legacyPath)
192
+ expire(legacyPath);
182
193
  },
183
194
  clearSessionCookie: () => {
184
- event.cookies.set(cookieName, '', { ...cookieOptionsFor(event), maxAge: 0 });
195
+ expire(cookiePath);
196
+ if (legacyPath)
197
+ expire(legacyPath);
185
198
  }
186
199
  };
187
200
  return applyCors(event, await route.handler(ctx));
@@ -29,19 +29,26 @@
29
29
  * so a cross-origin `POST /media` needs the site's origin in
30
30
  * `kit.csrf.trustedOrigins` — that check is production-only, so it will not
31
31
  * show up in `vela dev`.
32
+ *
33
+ * The session cookie is scoped to the mount path (`/cms`, or
34
+ * `/v1/projects/<id>/cms`), so several projects on one origin hold independent
35
+ * sessions and one browser can edit them all; `cookie.path` overrides it. The
36
+ * cookie only ever travels to the mount: the site's server-side reads
37
+ * (`loadCms`, prerender) carry preview and version keys instead, in a
38
+ * same-origin single-tenant mount as much as a cross-origin one.
32
39
  */
33
40
  export { createCmsBackend, type CmsBackend } from './factory.js';
34
41
  export { CmsDeployError } from './routes/deploy.js';
35
42
  export { localEditors, type LocalEditorsOptions } from './auth/local-editors.js';
36
43
  export { createEditorStore, type CmsEditorStore, type CreateEditorInput, type CmsSessionToken } from './auth/editors.js';
37
44
  export { hashPassword, verifyPassword, type ScryptParams } from './auth/scrypt.js';
38
- export { createCmsTestClient, type CmsTestClient, type TestResponse } from './testing/harness.js';
45
+ export { createCmsTestClient, type CmsCookieJar, type CmsTestClient, type JarEntry, type TestResponse } from './testing/harness.js';
39
46
  export { createTestFixture, TEST_SCRYPT, type TestFixture, type TestFixtureOptions } from './testing/fixtures.js';
40
47
  export type { CmsAuthAdapter, CmsAuthContext, CmsBackendOptions, CmsCookieOptions, CmsCorsOptions, CmsDeployAdapter, CmsDeployContext, CmsDeployRun, CmsDeployState, CmsEditor, CmsSessionGrant } from './types.js';
41
48
  export type { CmsStore } from './store/queries.js';
42
49
  export type { SqliteDb, SqliteStatement } from './store/sqlite.js';
43
50
  export type { Migration } from './store/migrations/index.js';
44
51
  export { MIGRATIONS } from './store/migrations/index.js';
45
- export type { DocsResponse, MediaItem, OpenRelease, PageMapEntry, PageMapRoute, PagesResponse, PagesRoute, PublishedRelease, ReleaseItem, SiteResponse } from '../core/wire.js';
52
+ export type { DocsResponse, MediaItem, OpenRelease, PageMapEntry, PageMapRoute, PagesResponse, PagesRoute, PublishedRelease, ReleaseItem, ReleasePublisher, SiteResponse } from '../core/wire.js';
46
53
  export type { PageDeleteOutcome, PageEntry } from '../core/page-entry.js';
47
54
  export type { Tree } from '../core/path.js';
@@ -29,6 +29,13 @@
29
29
  * so a cross-origin `POST /media` needs the site's origin in
30
30
  * `kit.csrf.trustedOrigins` — that check is production-only, so it will not
31
31
  * show up in `vela dev`.
32
+ *
33
+ * The session cookie is scoped to the mount path (`/cms`, or
34
+ * `/v1/projects/<id>/cms`), so several projects on one origin hold independent
35
+ * sessions and one browser can edit them all; `cookie.path` overrides it. The
36
+ * cookie only ever travels to the mount: the site's server-side reads
37
+ * (`loadCms`, prerender) carry preview and version keys instead, in a
38
+ * same-origin single-tenant mount as much as a cross-origin one.
32
39
  */
33
40
  export { createCmsBackend } from './factory.js';
34
41
  export { CmsDeployError } from './routes/deploy.js';
@@ -9,11 +9,16 @@
9
9
  import type { RequestEvent } from '@sveltejs/kit';
10
10
  import type { RouteCtx, RouteHandler } from './routes/context.js';
11
11
  /**
12
- * `public` — anonymous callers are served (published content only).
13
- * `required` — no editor is a 403.
14
- * `exempt` — skips the cross-project check, so an editor holding a session
15
- * for one project can still reach another's login form and log
16
- * out. Reproduces `isAuthEndpoint` from the hook this replaces.
12
+ * A session with no grant on this project is anonymous on this project, on
13
+ * every class:
14
+ *
15
+ * `public` — served to anyone; anonymous callers and foreign sessions get
16
+ * published content only.
17
+ * `required` — no editor authorized for this project is a 403.
18
+ * `exempt` — served regardless, so an editor holding a session for one
19
+ * project can reach another's login form and log out. The
20
+ * handler also sees `sessionUser`, so the login page can say why
21
+ * it is showing a form. Login and logout only.
17
22
  */
18
23
  export type RouteAuth = 'public' | 'required' | 'exempt';
19
24
  export type Route = {
@@ -16,8 +16,13 @@ export type RouteCtx = {
16
16
  event: RequestEvent;
17
17
  /** The tenant this request is for. `'default'` in a single-tenant mount. */
18
18
  projectId: string;
19
- /** The signed-in editor, or `null`. Routes declared `required` never see null. */
19
+ /** The editor whose session is authorized for this project, or `null` — on
20
+ * every route class. Routes declared `required` never see null. */
20
21
  user: CmsEditor | null;
22
+ /** The editor the session resolved to, whether or not they hold a grant
23
+ * here. Only the login page has a use for it: to say why the form is being
24
+ * shown instead of the signed-in screen. */
25
+ sessionUser: CmsEditor | null;
21
26
  /** Captured `:name` segments from the route pattern. */
22
27
  params: Record<string, string>;
23
28
  store: CmsStore;
@@ -10,10 +10,20 @@
10
10
  * The flow: the admin bar opens `<endpoint>/iframe/login` in an iframe, the
11
11
  * form posts back to the same URL, and on success the page tells the parent
12
12
  * window so the bar can re-fetch `/user` without a reload.
13
+ *
14
+ * Two identities matter here. `ctx.user` is an editor authorized for this
15
+ * project; `ctx.sessionUser` is whoever the cookie resolved to, grant or not.
16
+ * The signed-in screen may only render for `ctx.user`: the bar answers it by
17
+ * fetching `/user`, which is a 403 for anyone else, and a bar told "signed in"
18
+ * and then refused would reopen this page forever. A session that cannot edit
19
+ * this site gets the form instead, with a line saying why, so the editor can
20
+ * sign in as someone who can — without disturbing that other site's session,
21
+ * since the cookie is scoped to this mount.
13
22
  */
14
23
  import type { RouteCtx } from './context.js';
15
24
  export declare const getLogin: (ctx: RouteCtx) => Response;
16
25
  export declare const postLogin: (ctx: RouteCtx) => Promise<Response>;
17
26
  /** Kept so the admin bar's historical `/iframe/login/success` URL still
18
- * resolves; the POST above renders the same screen inline. */
27
+ * resolves; the POST above renders the same screen inline. `ctx.user` is only
28
+ * set for a session authorized here, so a foreign one is sent to the form. */
19
29
  export declare const getLoginSuccess: (ctx: RouteCtx) => Response;
@@ -53,6 +53,13 @@ button:hover { background: #262626; }
53
53
  background: #fef2f2;
54
54
  color: #b91c1c;
55
55
  }
56
+ .hint {
57
+ margin: 0 0 1rem;
58
+ padding: 0.5rem 0.75rem;
59
+ border-radius: 0.5rem;
60
+ background: #f5f5f5;
61
+ color: #525252;
62
+ }
56
63
  @media (prefers-color-scheme: dark) {
57
64
  body { background: #0a0a0a; color: #fafafa; }
58
65
  .card { background: #0a0a0a; border-color: #262626; }
@@ -62,6 +69,7 @@ button:hover { background: #262626; }
62
69
  button:hover { background: #e5e5e5; }
63
70
  p.sub { color: #a3a3a3; }
64
71
  .error { background: #2a1215; color: #fca5a5; }
72
+ .hint { background: #171717; color: #a3a3a3; }
65
73
  }
66
74
  `;
67
75
  const page = (title, body) => `<!doctype html>
@@ -79,8 +87,9 @@ ${body}
79
87
  </main>
80
88
  </body>
81
89
  </html>`;
82
- const loginForm = (error) => page('Sign in to edit', `<h1>Sign in to edit</h1>
90
+ const loginForm = (error, hint) => page('Sign in to edit', `<h1>Sign in to edit</h1>
83
91
  <p class="sub">Use your email and password</p>
92
+ ${hint ? `<p class="hint">${escapeHtml(hint)}</p>` : ''}
84
93
  ${error ? `<p class="error" role="alert">${escapeHtml(error)}</p>` : ''}
85
94
  <form method="POST">
86
95
  <div class="field">
@@ -110,15 +119,20 @@ const html = (body, ctx, status = 200) => new Response(body, {
110
119
  'cache-control': 'no-store'
111
120
  }
112
121
  });
122
+ /** Why a signed-in browser is looking at a form: its session cannot edit
123
+ * this site. Nothing when there is no session at all. */
124
+ const foreignSessionHint = (ctx) => ctx.sessionUser
125
+ ? `Signed in as ${ctx.sessionUser.email}, but that account can't edit this site. Sign in with one that can.`
126
+ : undefined;
113
127
  export const getLogin = (ctx) => {
114
128
  if (!ctx.auth.login)
115
129
  return new Response(null, { status: 404 });
116
- // Already signed in and authorized for this project — skip straight to the
117
- // success screen. A session bound to a project the caller can't reach leaves
118
- // `user` null, so they land on the form and can sign in as someone else.
130
+ // Authorized for this project already — straight to the signed-in screen.
119
131
  if (ctx.user)
120
132
  return html(successPage(), ctx);
121
- return html(loginForm(), ctx);
133
+ // Anonymous, or a session with no grant here: the form, never the
134
+ // signed-in screen (see the file header).
135
+ return html(loginForm(undefined, foreignSessionHint(ctx)), ctx);
122
136
  };
123
137
  export const postLogin = async (ctx) => {
124
138
  if (!ctx.auth.login)
@@ -139,11 +153,18 @@ export const postLogin = async (ctx) => {
139
153
  // One message either way — which half was wrong is not the caller's business.
140
154
  if (!grant)
141
155
  return html(loginForm('Invalid email or password.'), ctx, 400);
156
+ // Right password, wrong site. The session must not reach the browser: the
157
+ // bar would be told "signed in" and then refused by `/user`.
158
+ if (!(await ctx.auth.authorize(grant.user, ctx.projectId, ctx.authCtx))) {
159
+ await ctx.auth.discard?.(grant, ctx.authCtx);
160
+ return html(loginForm(`${grant.user.email} can't edit this site. Sign in with an account that can.`), ctx, 403);
161
+ }
142
162
  ctx.setSessionCookie(grant.token, grant.expiresAt);
143
163
  return html(successPage(), ctx);
144
164
  };
145
165
  /** Kept so the admin bar's historical `/iframe/login/success` URL still
146
- * resolves; the POST above renders the same screen inline. */
166
+ * resolves; the POST above renders the same screen inline. `ctx.user` is only
167
+ * set for a session authorized here, so a foreign one is sent to the form. */
147
168
  export const getLoginSuccess = (ctx) => {
148
169
  if (!ctx.user) {
149
170
  return new Response(null, {
@@ -63,6 +63,7 @@ const buildRoutes = (ctx, userId, locale) => {
63
63
  export const getPages = (ctx) => {
64
64
  const { event, projectId, store, respond } = ctx;
65
65
  const locale = event.url.searchParams.get('locale') ?? DEFAULT_LOCALE;
66
+ const previewKey = event.url.searchParams.get('preview');
66
67
  const versionKey = event.url.searchParams.get('version');
67
68
  if (versionKey) {
68
69
  const release = store.findPublishedReleaseByPreviewKey(projectId, versionKey);
@@ -102,6 +103,16 @@ export const getPages = (ctx) => {
102
103
  });
103
104
  return respond.serveImmutableJson(event, { routes });
104
105
  }
106
+ // Preview path: the key names an open release, overlaid exactly as its
107
+ // owner sees it, cache bypassed. `loadCms` asks this way on the editor's
108
+ // behalf because the server-side fetch carries no cookie — in a
109
+ // single-tenant mount as much as a cross-origin one, now that the cookie
110
+ // lives under the mount path. An unknown key falls through, as for /docs.
111
+ if (previewKey) {
112
+ const release = store.findOpenReleaseByPreviewKey(projectId, previewKey);
113
+ if (release)
114
+ return Response.json({ routes: buildRoutes(ctx, release.userId, locale) });
115
+ }
105
116
  const userId = ctx.user?.id ?? '';
106
117
  if (!userId) {
107
118
  const cacheCheck = respond.tryPublicCache(event, projectId, `pages:${locale}`);
@@ -101,7 +101,7 @@ export const postReleasePublish = async (ctx) => {
101
101
  name = body.name;
102
102
  }
103
103
  }
104
- const published = ctx.store.publishRelease(ctx.projectId, user.id, name);
104
+ const published = ctx.store.publishRelease(ctx.projectId, user, name);
105
105
  if (!published)
106
106
  return new Response('no open release to publish', { status: 404 });
107
107
  return Response.json({ release: published });
@@ -130,7 +130,7 @@ export const postReleaseRevert = (ctx) => {
130
130
  const id = ctx.params.id;
131
131
  if (!id)
132
132
  return badRequest('id required');
133
- const reverted = ctx.store.revertRelease(ctx.projectId, id, user.id);
133
+ const reverted = ctx.store.revertRelease(ctx.projectId, id, user);
134
134
  if (!reverted)
135
135
  return new Response('release not found', { status: 404 });
136
136
  return Response.json({ release: reverted });
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * The route table.
3
3
  *
4
- * `auth` semantics:
5
- * - `public` anonymous callers are served published content
6
- * - `required` no editor is a 403
7
- * - `exempt` skips the cross-project check, so an editor holding a session for
8
- * one project can still reach another's login form and log out
4
+ * `auth` semantics (a session with no grant here counts as anonymous):
5
+ * - `public` anyone is served published content
6
+ * - `required` no editor authorized for this project is a 403
7
+ * - `exempt` served regardless, so an editor holding a session for one
8
+ * project can still reach another's login form and log out
9
9
  */
10
10
  import type { Route } from '../router.js';
11
11
  export declare const ROUTES: Route[];
@@ -8,7 +8,9 @@ import { type RouteCtx } from './context.js';
8
8
  export declare const getUser: (ctx: RouteCtx) => Response;
9
9
  /**
10
10
  * Idempotent — safe to call without being signed in. Clears the cookie with
11
- * the same attributes it was set with, so the browser accepts the expiry, and
12
- * lets the adapter drop server-side session state.
11
+ * the same attributes it was set with, so the browser accepts the expiry — and,
12
+ * while the default path is in use, the root-scoped cookie versions before
13
+ * 0.3.1 set — and lets the adapter drop server-side state for every session
14
+ * the request presented.
13
15
  */
14
16
  export declare const postLogout: (ctx: RouteCtx) => Promise<Response>;
@@ -11,8 +11,10 @@ export const getUser = (ctx) => {
11
11
  };
12
12
  /**
13
13
  * Idempotent — safe to call without being signed in. Clears the cookie with
14
- * the same attributes it was set with, so the browser accepts the expiry, and
15
- * lets the adapter drop server-side session state.
14
+ * the same attributes it was set with, so the browser accepts the expiry — and,
15
+ * while the default path is in use, the root-scoped cookie versions before
16
+ * 0.3.1 set — and lets the adapter drop server-side state for every session
17
+ * the request presented.
16
18
  */
17
19
  export const postLogout = async (ctx) => {
18
20
  await ctx.auth.logout?.(ctx.event, ctx.authCtx);
@@ -0,0 +1 @@
1
+ export declare const sql = "\nALTER TABLE releases ADD COLUMN published_by_email TEXT;\nALTER TABLE releases ADD COLUMN published_by_name TEXT;\nUPDATE releases SET\n published_by_email = (SELECT e.email FROM cms_editors e WHERE e.id = releases.published_by),\n published_by_name = (SELECT e.name FROM cms_editors e WHERE e.id = releases.published_by);\n";
@@ -0,0 +1,15 @@
1
+ // Who published a release, as they were at the time.
2
+ //
3
+ // `published_by` is an opaque editor id, which is all the auth adapter
4
+ // promises to keep stable — and all the admin bar could show. The publisher's
5
+ // email and name are now captured on the row at publish time, so history reads
6
+ // the same under any adapter and survives the account being renamed or
7
+ // removed. Existing rows are filled from `cms_editors` where the id still
8
+ // resolves; the rest stay null and readers fall back to the id.
9
+ export const sql = `
10
+ ALTER TABLE releases ADD COLUMN published_by_email TEXT;
11
+ ALTER TABLE releases ADD COLUMN published_by_name TEXT;
12
+ UPDATE releases SET
13
+ published_by_email = (SELECT e.email FROM cms_editors e WHERE e.id = releases.published_by),
14
+ published_by_name = (SELECT e.name FROM cms_editors e WHERE e.id = releases.published_by);
15
+ `;
@@ -1,6 +1,7 @@
1
1
  import { sql as init } from './0001-init.js';
2
2
  import { sql as site } from './0002-site.js';
3
3
  import { sql as editors } from './0003-editors.js';
4
+ import { sql as releasePublisher } from './0004-release-publisher.js';
4
5
  /**
5
6
  * Every migration, in apply order.
6
7
  *
@@ -14,5 +15,6 @@ import { sql as editors } from './0003-editors.js';
14
15
  export const MIGRATIONS = [
15
16
  { name: '0001_init.sql', sql: init },
16
17
  { name: '0002_site.sql', sql: site },
17
- { name: '0003_editors.sql', sql: editors }
18
+ { name: '0003_editors.sql', sql: editors },
19
+ { name: '0004_release_publisher.sql', sql: releasePublisher }
18
20
  ];
@@ -2,6 +2,7 @@ import { type PageEntry, type PageDeleteOutcome } from '../../core/page-entry.js
2
2
  import { type Tree } from '../../core/path.js';
3
3
  import type { AddReleaseItemInput, CreatePageResult, DiscardItemTarget, ListMediaOptions, ListMediaResult, MediaItem, OpenRelease, PageMapRoute, PublishedRelease, ReleaseItem, RenamePageResult, StagePageDeleteResult } from '../../core/wire.js';
4
4
  import type { SqliteDb } from './sqlite.js';
5
+ import type { CmsEditor } from '../types.js';
5
6
  /** The locale a request falls back to when it names none. */
6
7
  export declare const DEFAULT_LOCALE = "en";
7
8
  /** An opaque, unguessable key for a release's preview URL. */
@@ -50,7 +51,7 @@ export declare const createStore: (db: SqliteDb) => {
50
51
  id: string;
51
52
  items: ReleaseItem[];
52
53
  } | null;
53
- publishRelease: (projectId: string, userId: string, name?: string) => PublishedRelease | null;
54
+ publishRelease: (projectId: string, user: CmsEditor, name?: string) => PublishedRelease | null;
54
55
  regeneratePreviewKey: (projectId: string, userId: string) => string | null;
55
56
  regeneratePublishedPreviewKey: (projectId: string, releaseId: string) => string | null;
56
57
  renamePage: (projectId: string, userId: string, args: {
@@ -60,7 +61,7 @@ export declare const createStore: (db: SqliteDb) => {
60
61
  toParams: Record<string, string>;
61
62
  toUrl: string;
62
63
  }) => RenamePageResult;
63
- revertRelease: (projectId: string, releaseId: string, userId: string) => PublishedRelease | null;
64
+ revertRelease: (projectId: string, releaseId: string, user: CmsEditor) => PublishedRelease | null;
64
65
  seedPublishedDocs: (projectId: string, layouts: Record<string, Record<string, Tree>>, pages: Record<string, Record<string, PageEntry[]>>) => void;
65
66
  stagePageDelete: (projectId: string, userId: string, routeId: string, locale: string, params: Record<string, string>, outcome?: PageDeleteOutcome) => StagePageDeleteResult;
66
67
  };
@@ -517,7 +517,7 @@ export const createStore = (db) => {
517
517
  updated_by_release = excluded.updated_by_release`);
518
518
  const updatePublishedPageTombstoneStmt = () => db.prepare('UPDATE published_pages SET tombstone = ?, updated_at = ?, updated_by_release = ? WHERE project_id = ? AND locale = ? AND route_id = ? AND params_hash = ?');
519
519
  const deletePublishedPageStmt = () => db.prepare('DELETE FROM published_pages WHERE project_id = ? AND locale = ? AND route_id = ? AND params_hash = ?');
520
- const insertReleaseStmt = () => db.prepare('INSERT INTO releases (id, project_id, seq, name, published_by, published_at, preview_key) VALUES (?, ?, ?, ?, ?, ?, ?)');
520
+ const insertReleaseStmt = () => db.prepare('INSERT INTO releases (id, project_id, seq, name, published_by, published_by_email, published_by_name, published_at, preview_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
521
521
  const insertReleaseItemStmt = () => db.prepare('INSERT INTO release_items (release_id, project_id, seq, kind, route_id, locale, params_hash, params, tree, prior_tree, outcome, prior_tombstone, added_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
522
522
  const deleteOpenItemsForUserStmt = () => db.prepare('DELETE FROM open_release_items WHERE project_id = ? AND user_id = ?');
523
523
  /**
@@ -527,7 +527,8 @@ export const createStore = (db) => {
527
527
  * open release. Returns the new `PublishedRelease`, or null if there's nothing
528
528
  * to publish.
529
529
  */
530
- const publishRelease = (projectId, userId, name) => {
530
+ const publishRelease = (projectId, user, name) => {
531
+ const userId = user.id;
531
532
  const tx = db.transaction(() => {
532
533
  ensureProjectState(projectId);
533
534
  const releaseRow = getOpenReleaseStmt().get(projectId, userId);
@@ -546,7 +547,7 @@ export const createStore = (db) => {
546
547
  // resolves on every per-item insert below. We delete the header at the
547
548
  // end if the body turned out to be empty.
548
549
  const finalName = name ?? releaseRow.name ?? null;
549
- insertReleaseStmt().run(releaseId, projectId, seq, finalName, userId, publishedAt, previewKey);
550
+ insertReleaseStmt().run(releaseId, projectId, seq, finalName, user.id, user.email, user.name, publishedAt, previewKey);
550
551
  for (const item of items) {
551
552
  if (item.kind === 'site') {
552
553
  const target = getPublishedSiteTree(projectId);
@@ -635,7 +636,7 @@ export const createStore = (db) => {
635
636
  deleteOpenReleaseStmt().run(projectId, userId);
636
637
  const result = {
637
638
  id: releaseId,
638
- publishedBy: userId,
639
+ publishedBy: { id: user.id, email: user.email, name: user.name },
639
640
  publishedAt,
640
641
  preview_key: previewKey,
641
642
  items: publishedItems
@@ -646,9 +647,9 @@ export const createStore = (db) => {
646
647
  });
647
648
  return tx();
648
649
  };
649
- const listReleaseHistoryStmt = () => db.prepare('SELECT id, name, published_by, published_at, preview_key, reverted_at FROM releases WHERE project_id = ? ORDER BY seq ASC');
650
- const findReleaseByPreviewKeyStmt = () => db.prepare('SELECT id, name, published_by, published_at, preview_key, reverted_at, seq FROM releases WHERE project_id = ? AND preview_key = ?');
651
- const getReleaseByIdStmt = () => db.prepare('SELECT id, name, published_by, published_at, preview_key, reverted_at, seq FROM releases WHERE project_id = ? AND id = ?');
650
+ const listReleaseHistoryStmt = () => db.prepare('SELECT id, name, published_by, published_by_email, published_by_name, published_at, preview_key, reverted_at FROM releases WHERE project_id = ? ORDER BY seq ASC');
651
+ const findReleaseByPreviewKeyStmt = () => db.prepare('SELECT id, name, published_by, published_by_email, published_by_name, published_at, preview_key, reverted_at, seq FROM releases WHERE project_id = ? AND preview_key = ?');
652
+ const getReleaseByIdStmt = () => db.prepare('SELECT id, name, published_by, published_by_email, published_by_name, published_at, preview_key, reverted_at, seq FROM releases WHERE project_id = ? AND id = ?');
652
653
  const listReleaseItemsByReleaseIdStmt = () => db.prepare('SELECT kind, route_id, locale, params, tree, prior_tree, outcome, prior_tombstone FROM release_items WHERE release_id = ? ORDER BY id');
653
654
  const listReleaseItemsAfterSeqStmt = () => db.prepare('SELECT seq, kind, route_id, locale, params, tree, prior_tree, outcome, prior_tombstone FROM release_items WHERE project_id = ? AND seq > ? ORDER BY seq DESC, id DESC');
654
655
  const updateReleasePreviewKeyStmt = () => db.prepare('UPDATE releases SET preview_key = ? WHERE id = ?');
@@ -702,7 +703,11 @@ export const createStore = (db) => {
702
703
  const items = listReleaseItemsByReleaseIdStmt().all(row.id).map(rowToPublishedItem);
703
704
  const r = {
704
705
  id: row.id,
705
- publishedBy: row.published_by,
706
+ publishedBy: {
707
+ id: row.published_by,
708
+ email: row.published_by_email,
709
+ name: row.published_by_name
710
+ },
706
711
  publishedAt: row.published_at,
707
712
  preview_key: row.preview_key,
708
713
  items
@@ -822,7 +827,7 @@ export const createStore = (db) => {
822
827
  // ---------------------------------------------------------------------------
823
828
  // Revert
824
829
  // ---------------------------------------------------------------------------
825
- const revertRelease = (projectId, releaseId, userId) => {
830
+ const revertRelease = (projectId, releaseId, user) => {
826
831
  const tx = db.transaction(() => {
827
832
  ensureProjectState(projectId);
828
833
  const sourceRow = getReleaseByIdStmt().get(projectId, releaseId);
@@ -839,7 +844,7 @@ export const createStore = (db) => {
839
844
  const inverseName = sourceRow.name
840
845
  ? `Revert "${sourceRow.name}"`
841
846
  : `Revert ${sourceRow.id.slice(0, 8)}`;
842
- insertReleaseStmt().run(inverseId, projectId, seq, inverseName, userId, publishedAt, previewKey);
847
+ insertReleaseStmt().run(inverseId, projectId, seq, inverseName, user.id, user.email, user.name, publishedAt, previewKey);
843
848
  for (const item of sourceItems) {
844
849
  if (item.kind === 'site') {
845
850
  const restoreTree = item.priorTree ?? {};
@@ -962,7 +967,7 @@ export const createStore = (db) => {
962
967
  return {
963
968
  id: inverseId,
964
969
  name: inverseName,
965
- publishedBy: userId,
970
+ publishedBy: { id: user.id, email: user.email, name: user.name },
966
971
  publishedAt,
967
972
  preview_key: previewKey,
968
973
  items: inverseItems
@@ -12,9 +12,11 @@ export type TestFixture = {
12
12
  backend: CmsBackend;
13
13
  /** Unauthenticated client for the default project. */
14
14
  anon: CmsTestClient;
15
- /** Signed in as alice, who can reach `p1` and `p_alice`. */
15
+ /** Signed in as alice, who can reach `p1` and `p_alice`. Holds a root-scoped
16
+ * cookie, so `at()` carries it to any mount and cross-project tests
17
+ * exercise `authorize`. */
16
18
  alice: CmsTestClient;
17
- /** Signed in as bob, who can reach `p1` and `p_bob`. */
19
+ /** Signed in as bob, who can reach `p1` and `p_bob`. Root-scoped like alice. */
18
20
  bob: CmsTestClient;
19
21
  users: {
20
22
  alice: CmsEditor;
@@ -11,17 +11,36 @@ export type RequestInitLike = {
11
11
  headers?: Record<string, string>;
12
12
  cookie?: string;
13
13
  };
14
+ export type JarEntry = {
15
+ name: string;
16
+ value: string;
17
+ path: string;
18
+ };
19
+ /** The client's cookies, kept by name and path the way a browser keeps them. */
20
+ export type CmsCookieJar = {
21
+ /** The value the client would send to its own mount, or `undefined`. */
22
+ get(name: string): string | undefined;
23
+ set(name: string, value: string, path: string): void;
24
+ delete(name: string, path: string): void;
25
+ /** Every stored cookie with its path, for asserting on scoping. */
26
+ all(): readonly JarEntry[];
27
+ /** The `cookie` header a request to `pathname` would carry. */
28
+ headerFor(pathname: string): string | undefined;
29
+ };
14
30
  export type CmsTestClient = {
15
31
  get(path: string, init?: RequestInitLike): Promise<TestResponse>;
16
32
  post(path: string, init?: RequestInitLike): Promise<TestResponse>;
17
33
  del(path: string, init?: RequestInitLike): Promise<TestResponse>;
18
34
  request(method: string, path: string, init?: RequestInitLike): Promise<TestResponse>;
19
- /** A client that sends this cookie on every request. */
20
- as(cookie: string): CmsTestClient;
35
+ /** A client that sends this cookie on every request. Root-scoped by
36
+ * default — what a login from before 0.3.1 left behind, and what lets
37
+ * `at()` carry it to another mount so cross-project authorization is
38
+ * exercised. Pass a path to model a mount-scoped cookie. */
39
+ as(cookie: string, path?: string): CmsTestClient;
21
40
  /** A client mounted at a different base path / route params. */
22
41
  at(basePath: string, params?: Record<string, string>): CmsTestClient;
23
42
  /** The cookie jar accumulated from `set-cookie` responses. */
24
- cookies: Map<string, string>;
43
+ cookies: CmsCookieJar;
25
44
  };
26
45
  export type TestClientOptions = {
27
46
  /** Where the backend is mounted, e.g. `/v1/projects/p1/cms`. */
@@ -1,3 +1,31 @@
1
+ /** RFC 6265 §5.1.4. */
2
+ const pathMatches = (requestPath, cookiePath) => requestPath === cookiePath ||
3
+ (requestPath.startsWith(cookiePath) &&
4
+ (cookiePath.endsWith('/') || requestPath[cookiePath.length] === '/'));
5
+ /** Longest path first, as RFC 6265 §5.4 asks of browsers; the sort is stable,
6
+ * so equal paths keep their order of arrival. */
7
+ const byPathLength = (list) => list.sort((a, b) => b.path.length - a.path.length);
8
+ const createJar = (basePath) => {
9
+ // Keyed the way SvelteKit keys cookies it sets: by path and name.
10
+ const entries = new Map();
11
+ const key = (name, path) => `${path}?${name}`;
12
+ const matching = (pathname) => byPathLength([...entries.values()].filter((e) => pathMatches(pathname, e.path)));
13
+ return {
14
+ get: (name) => matching(basePath).find((e) => e.name === name)?.value,
15
+ set: (name, value, path) => {
16
+ entries.set(key(name, path), { name, value, path });
17
+ },
18
+ delete: (name, path) => {
19
+ entries.delete(key(name, path));
20
+ },
21
+ all: () => [...entries.values()],
22
+ headerFor: (pathname) => {
23
+ const list = matching(pathname);
24
+ return list.length > 0 ? list.map((e) => `${e.name}=${e.value}`).join('; ') : undefined;
25
+ }
26
+ };
27
+ };
28
+ /** Like `cookie.parse`: the first of a duplicate name wins. */
1
29
  const parseCookieHeader = (header) => {
2
30
  const out = new Map();
3
31
  if (!header)
@@ -6,7 +34,9 @@ const parseCookieHeader = (header) => {
6
34
  const eq = part.indexOf('=');
7
35
  if (eq === -1)
8
36
  continue;
9
- out.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
37
+ const name = part.slice(0, eq).trim();
38
+ if (!out.has(name))
39
+ out.set(name, part.slice(eq + 1).trim());
10
40
  }
11
41
  return out;
12
42
  };
@@ -32,12 +62,12 @@ export const createCmsTestClient = (backend, options) => {
32
62
  const restParam = options.restParam ?? 'path';
33
63
  const origin = options.origin ?? 'http://localhost';
34
64
  const basePath = options.basePath.replace(/\/$/, '');
35
- const jar = new Map();
65
+ const jar = createJar(basePath);
36
66
  const run = async (method, path, init = {}) => {
37
67
  const url = new URL(`${basePath}${path.startsWith('/') ? path : `/${path}`}`, origin);
38
68
  const rest = url.pathname.slice(basePath.length).replace(/^\//, '');
39
69
  const headers = new Headers(init.headers ?? {});
40
- const cookieHeader = init.cookie ?? (jar.size > 0 ? [...jar].map(([k, v]) => `${k}=${v}`).join('; ') : undefined);
70
+ const cookieHeader = init.cookie ?? jar.headerFor(url.pathname);
41
71
  if (cookieHeader)
42
72
  headers.set('cookie', cookieHeader);
43
73
  let body;
@@ -65,18 +95,30 @@ export const createCmsTestClient = (backend, options) => {
65
95
  accumulated[lower] = newHeaders[key];
66
96
  }
67
97
  };
98
+ // Mirrors SvelteKit: `set` insists on a path; a cookie set during this
99
+ // request shadows the one it carried, longest path first; one expired
100
+ // here reads as absent.
101
+ const newCookies = new Map();
68
102
  const pending = [];
103
+ const fresh = (name) => byPathLength([...newCookies.values()].filter((c) => c.name === name && pathMatches(url.pathname, c.path)))[0];
104
+ const set = (name, value, opts) => {
105
+ if (typeof opts.path !== 'string') {
106
+ throw new Error('You must specify a `path` when setting, deleting or serializing cookies');
107
+ }
108
+ const maxAge = typeof opts.maxAge === 'number' ? opts.maxAge : undefined;
109
+ newCookies.set(`${opts.path}?${name}`, { name, value, path: opts.path, maxAge });
110
+ pending.push(serializeCookie(name, value, opts));
111
+ };
69
112
  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 }));
113
+ get: (name) => {
114
+ const c = fresh(name);
115
+ if (c)
116
+ return c.maxAge === 0 ? undefined : c.value;
117
+ return requestCookies.get(name);
79
118
  },
119
+ getAll: () => [...requestCookies].map(([name, value]) => ({ name, value })),
120
+ set,
121
+ delete: (name, opts) => set(name, '', { ...opts, maxAge: 0 }),
80
122
  serialize: serializeCookie
81
123
  };
82
124
  const event = {
@@ -101,15 +143,11 @@ export const createCmsTestClient = (backend, options) => {
101
143
  response.headers.set(k, v);
102
144
  for (const c of pending)
103
145
  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);
146
+ for (const c of newCookies.values()) {
147
+ if (c.maxAge === 0)
148
+ jar.delete(c.name, c.path);
111
149
  else
112
- jar.set(name, value);
150
+ jar.set(c.name, c.value, c.path);
113
151
  }
114
152
  const text = await response.text();
115
153
  return {
@@ -124,23 +162,25 @@ export const createCmsTestClient = (backend, options) => {
124
162
  get: (path, init) => run('GET', path, init),
125
163
  post: (path, init) => run('POST', path, init),
126
164
  del: (path, init) => run('DELETE', path, init),
127
- as: (cookie) => {
165
+ as: (cookie, path = '/') => {
128
166
  const scoped = createCmsTestClient(backend, options);
129
167
  const [name, ...rest] = cookie.split('=');
130
- scoped.cookies.set(name.trim(), rest.join('='));
168
+ scoped.cookies.set(name.trim(), rest.join('='), path);
131
169
  return scoped;
132
170
  },
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.
171
+ // Carries the cookie jar across, paths included, so
172
+ // `client.as(session).at(otherProject)` is the same browser visiting a
173
+ // different mount — which is exactly the cross-project authorization
174
+ // case — and a mount-scoped cookie stays behind the way it would in a
175
+ // browser.
136
176
  at: (nextBasePath, params) => {
137
177
  const scoped = createCmsTestClient(backend, {
138
178
  ...options,
139
179
  basePath: nextBasePath,
140
180
  params
141
181
  });
142
- for (const [k, v] of jar)
143
- scoped.cookies.set(k, v);
182
+ for (const e of jar.all())
183
+ scoped.cookies.set(e.name, e.value, e.path);
144
184
  return scoped;
145
185
  },
146
186
  cookies: jar
@@ -34,14 +34,22 @@ export type CmsAuthAdapter = {
34
34
  /** Resolve the request's editor, or `null` for anonymous. Must not throw on
35
35
  * a malformed or stale cookie: anonymous is a valid outcome, not an error. */
36
36
  resolve(event: RequestEvent, ctx: CmsAuthContext): Promise<CmsEditor | null>;
37
- /** May this editor act on this project? A `false` here is a 403 before any
38
- * handler runs. */
37
+ /** May this editor act on this project? A `false` makes the session
38
+ * anonymous on this project: `required` routes 403, `public` routes serve
39
+ * published content, and the login page shows its form. */
39
40
  authorize(user: CmsEditor, projectId: string, ctx: CmsAuthContext): Promise<boolean>;
40
41
  /** Backs the built-in login page. Omit it and `/iframe/login` 404s, leaving
41
- * login entirely to the host. */
42
+ * login entirely to the host. The router runs `authorize` on the grant
43
+ * before issuing it, so an implementation need not check the project. */
42
44
  login?(email: string, password: string, ctx: CmsAuthContext): Promise<CmsSessionGrant | null>;
43
- /** Invalidate server-side session state on logout. The cookie is cleared
44
- * regardless. */
45
+ /** Throw away a grant `login` minted but the router refused to issue,
46
+ * because the account has no grant on this project. Optional: without it
47
+ * the session stays valid server-side, but its token never left the
48
+ * process. */
49
+ discard?(grant: CmsSessionGrant, ctx: CmsAuthContext): Promise<void>;
50
+ /** Invalidate server-side session state on logout — every session the
51
+ * request presented, not only the one `cookies.get` returns. The cookie is
52
+ * cleared regardless. */
45
53
  logout?(event: RequestEvent, ctx: CmsAuthContext): Promise<void>;
46
54
  };
47
55
  /** Handed to every deploy adapter method. `store` is the CMS store, so the
@@ -69,6 +77,14 @@ export type CmsDeployAdapter = {
69
77
  };
70
78
  export type CmsCookieOptions = {
71
79
  name?: string;
80
+ /**
81
+ * Defaults to the mount path (`/cms`, `/v1/projects/<id>/cms`), so projects
82
+ * sharing an origin hold independent sessions: signing in on one site never
83
+ * touches another's. While unset, login and logout also expire a
84
+ * root-scoped cookie left by versions before 0.3.1. Nothing outside the
85
+ * mount needs it: the site's own server reads through preview and version
86
+ * keys, never the cookie. Set `'/'` to put it back at the origin root.
87
+ */
72
88
  path?: string;
73
89
  sameSite?: 'lax' | 'strict' | 'none';
74
90
  secure?: boolean;
@@ -26,7 +26,7 @@
26
26
  type PublishedRelease = {
27
27
  id: string;
28
28
  name?: string;
29
- publishedBy: string;
29
+ publishedBy: { id: string; email: string | null; name: string | null };
30
30
  publishedAt: string;
31
31
  preview_key: string;
32
32
  revertedAt?: string;
@@ -206,10 +206,9 @@
206
206
  {/if}
207
207
  </div>
208
208
  <div class="vela:text-[11px] vela:text-bar-text-tertiary vela:mt-0.5">
209
- {formatDate(release.publishedAt)} · published by {release.publishedBy}{isLive
210
- ? ' · '
211
- : ''}{#if isLive}<span class="vela:text-[var(--cms-status-clean-dot)]"
212
- >live now</span
209
+ {formatDate(release.publishedAt)} · published by {release.publishedBy.email ??
210
+ release.publishedBy.id}{isLive ? ' · ' : ''}{#if isLive}<span
211
+ class="vela:text-[var(--cms-status-clean-dot)]">live now</span
213
212
  >{/if}
214
213
  </div>
215
214
  {#if paths.length > 0}
@@ -54,10 +54,20 @@ export type PublishedReleaseItem = ReleaseItemCore & {
54
54
  priorTree: Tree | null;
55
55
  priorTombstone?: PageDeleteOutcome;
56
56
  };
57
+ /**
58
+ * The editor who published a release, captured at publish time. `email` and
59
+ * `name` are null only on rows published before they were recorded, where the
60
+ * account no longer resolves.
61
+ */
62
+ export type ReleasePublisher = {
63
+ id: string;
64
+ email: string | null;
65
+ name: string | null;
66
+ };
57
67
  export type PublishedRelease = {
58
68
  id: string;
59
69
  name?: string;
60
- publishedBy: string;
70
+ publishedBy: ReleasePublisher;
61
71
  publishedAt: string;
62
72
  preview_key: string;
63
73
  items: PublishedReleaseItem[];
@@ -129,7 +129,8 @@ export const apiAdapter = (options) => {
129
129
  },
130
130
  async fetchEntries(routeId, context) {
131
131
  // Without `previewKey`: `/pages` is read-public; the backend returns
132
- // published entries only when no `cms_session` cookie is present.
132
+ // published entries only when the request carries no session
133
+ // authorized for this project.
133
134
  // For prerender we don't pass a cookie — we get published-only by
134
135
  // construction. We still filter `isDraft` / `isDeletePending` /
135
136
  // `gone` as a defensive belt-and-suspenders. Tombstoned entries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velastack/cms",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A scope-aware CMS for SvelteKit, for static or dynamic sites.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "type": "module",
24
24
  "engines": {
25
- "node": "^20.19.0 || >=22.12.0"
25
+ "node": ">=22.12.0"
26
26
  },
27
27
  "scripts": {
28
28
  "dev": "vite dev",
@@ -76,7 +76,7 @@
76
76
  },
77
77
  "peerDependencies": {
78
78
  "@sveltejs/kit": "^2.0.0",
79
- "better-sqlite3": "^11.0.0 || ^12.0.0",
79
+ "better-sqlite3": "^12.0.0 || ^13.0.0",
80
80
  "marked": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0",
81
81
  "svelte": "^5.0.0",
82
82
  "vite": "^8.0.0"
@@ -98,7 +98,7 @@
98
98
  "@tailwindcss/cli": "^4.3.3",
99
99
  "@tailwindcss/vite": "^4.3.3",
100
100
  "@types/node": "^26.4.0",
101
- "better-sqlite3": "^12.11.1",
101
+ "better-sqlite3": "^13.0.3",
102
102
  "marked": "^18.0.11",
103
103
  "prettier": "^3.9.6",
104
104
  "prettier-plugin-svelte": "^4.1.1",