@velastack/cms 0.2.6 → 0.3.1
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/local-editors.js +32 -3
- package/dist/backend/factory.js +31 -16
- package/dist/backend/index.d.ts +11 -2
- package/dist/backend/index.js +8 -0
- package/dist/backend/router.d.ts +10 -5
- package/dist/backend/routes/context.d.ts +9 -2
- package/dist/backend/routes/deploy.d.ts +9 -0
- package/dist/backend/routes/deploy.js +46 -0
- package/dist/backend/routes/login.d.ts +11 -1
- package/dist/backend/routes/login.js +27 -6
- package/dist/backend/routes/pages.js +11 -0
- package/dist/backend/routes/table.d.ts +5 -5
- package/dist/backend/routes/table.js +3 -0
- package/dist/backend/routes/user.d.ts +4 -2
- package/dist/backend/routes/user.js +4 -2
- package/dist/backend/testing/fixtures.d.ts +4 -2
- package/dist/backend/testing/harness.d.ts +22 -3
- package/dist/backend/testing/harness.js +67 -27
- package/dist/backend/types.d.ts +51 -5
- package/dist/components/admin-bar/admin-bar-internal.svelte +95 -1
- package/dist/components/admin-bar/deploy-dialog.svelte +76 -0
- package/dist/components/admin-bar/deploy-dialog.svelte.d.ts +15 -0
- package/dist/components/admin-bar/deploy-state.d.ts +7 -0
- package/dist/components/admin-bar/deploy-state.js +13 -0
- package/dist/components/admin-bar/format-relative.d.ts +6 -0
- package/dist/components/admin-bar/format-relative.js +37 -0
- package/dist/components/admin-bar/publish-dialog.svelte +1 -30
- package/dist/core/wire.d.ts +17 -0
- package/dist/server/api-adapter.js +2 -1
- package/package.json +1 -1
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
};
|
package/dist/backend/factory.js
CHANGED
|
@@ -37,6 +37,7 @@ export const createCmsBackend = (options = {}) => {
|
|
|
37
37
|
const storage = createStorage(uploadDir);
|
|
38
38
|
const editors = createEditorStore(db);
|
|
39
39
|
const auth = options.auth ?? localEditors();
|
|
40
|
+
const deploy = options.deploy;
|
|
40
41
|
const restParam = options.restParam ?? 'path';
|
|
41
42
|
const cookieName = options.cookie?.name ?? 'cms_session';
|
|
42
43
|
const resolveProject = options.resolveProject ?? (() => 'default');
|
|
@@ -78,13 +79,14 @@ export const createCmsBackend = (options = {}) => {
|
|
|
78
79
|
* Cookie attributes. `sameSite: 'none'` is required for the cross-origin
|
|
79
80
|
* iframe login and wrong for a same-origin mount on plain http, so the
|
|
80
81
|
* default follows whether CORS is configured rather than being hardcoded
|
|
81
|
-
* 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.
|
|
82
84
|
*/
|
|
83
|
-
const cookieOptionsFor = (event) => {
|
|
85
|
+
const cookieOptionsFor = (event, path) => {
|
|
84
86
|
const crossOrigin = cors !== false;
|
|
85
87
|
const isHttps = event.url.protocol === 'https:';
|
|
86
88
|
return {
|
|
87
|
-
path
|
|
89
|
+
path,
|
|
88
90
|
httpOnly: true,
|
|
89
91
|
sameSite: options.cookie?.sameSite ?? (crossOrigin ? 'none' : 'lax'),
|
|
90
92
|
secure: options.cookie?.secure ?? (crossOrigin ? true : isHttps),
|
|
@@ -139,47 +141,60 @@ export const createCmsBackend = (options = {}) => {
|
|
|
139
141
|
const projectId = resolveProject(event);
|
|
140
142
|
if (projectId === null)
|
|
141
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;
|
|
142
151
|
const authCtx = { db, projectId, cookieName };
|
|
143
|
-
// Resolve, then authorize,
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
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;
|
|
147
159
|
try {
|
|
148
|
-
|
|
160
|
+
sessionUser = await auth.resolve(event, authCtx);
|
|
149
161
|
}
|
|
150
162
|
catch {
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
if (user && route.auth !== 'exempt' && !(await auth.authorize(user, projectId, authCtx))) {
|
|
154
|
-
return applyCors(event, new Response(null, { status: 403 }));
|
|
163
|
+
sessionUser = null;
|
|
155
164
|
}
|
|
165
|
+
const user = sessionUser && (await auth.authorize(sessionUser, projectId, authCtx)) ? sessionUser : null;
|
|
156
166
|
if (route.auth === 'required' && !user) {
|
|
157
167
|
return applyCors(event, new Response(null, { status: 403 }));
|
|
158
168
|
}
|
|
159
|
-
const
|
|
169
|
+
const expire = (path) => event.cookies.set(cookieName, '', { ...cookieOptionsFor(event, path), maxAge: 0 });
|
|
160
170
|
const ctx = {
|
|
161
171
|
event,
|
|
162
172
|
projectId,
|
|
163
173
|
user,
|
|
174
|
+
sessionUser,
|
|
164
175
|
params,
|
|
165
176
|
store,
|
|
166
177
|
cache,
|
|
167
178
|
respond,
|
|
168
179
|
storage,
|
|
169
180
|
auth,
|
|
181
|
+
deploy,
|
|
170
182
|
authCtx,
|
|
171
183
|
mediaUrl: mediaUrlFor(event),
|
|
172
184
|
mountPath,
|
|
173
185
|
frameAncestors,
|
|
174
186
|
setSessionCookie: (token, expiresAt) => {
|
|
175
|
-
const opts = cookieOptionsFor(event);
|
|
176
187
|
event.cookies.set(cookieName, token, {
|
|
177
|
-
...
|
|
188
|
+
...cookieOptionsFor(event, cookiePath),
|
|
178
189
|
maxAge: Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000))
|
|
179
190
|
});
|
|
191
|
+
if (legacyPath)
|
|
192
|
+
expire(legacyPath);
|
|
180
193
|
},
|
|
181
194
|
clearSessionCookie: () => {
|
|
182
|
-
|
|
195
|
+
expire(cookiePath);
|
|
196
|
+
if (legacyPath)
|
|
197
|
+
expire(legacyPath);
|
|
183
198
|
}
|
|
184
199
|
};
|
|
185
200
|
return applyCors(event, await route.handler(ctx));
|
package/dist/backend/index.d.ts
CHANGED
|
@@ -29,14 +29,23 @@
|
|
|
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';
|
|
41
|
+
export { CmsDeployError } from './routes/deploy.js';
|
|
34
42
|
export { localEditors, type LocalEditorsOptions } from './auth/local-editors.js';
|
|
35
43
|
export { createEditorStore, type CmsEditorStore, type CreateEditorInput, type CmsSessionToken } from './auth/editors.js';
|
|
36
44
|
export { hashPassword, verifyPassword, type ScryptParams } from './auth/scrypt.js';
|
|
37
|
-
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';
|
|
38
46
|
export { createTestFixture, TEST_SCRYPT, type TestFixture, type TestFixtureOptions } from './testing/fixtures.js';
|
|
39
|
-
export type { CmsAuthAdapter, CmsAuthContext, CmsBackendOptions, CmsCookieOptions, CmsCorsOptions, CmsEditor, CmsSessionGrant } from './types.js';
|
|
47
|
+
export type { CmsAuthAdapter, CmsAuthContext, CmsBackendOptions, CmsCookieOptions, CmsCorsOptions, CmsDeployAdapter, CmsDeployContext, CmsDeployRun, CmsDeployState, CmsEditor, CmsSessionGrant } from './types.js';
|
|
48
|
+
export type { CmsStore } from './store/queries.js';
|
|
40
49
|
export type { SqliteDb, SqliteStatement } from './store/sqlite.js';
|
|
41
50
|
export type { Migration } from './store/migrations/index.js';
|
|
42
51
|
export { MIGRATIONS } from './store/migrations/index.js';
|
package/dist/backend/index.js
CHANGED
|
@@ -29,8 +29,16 @@
|
|
|
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';
|
|
41
|
+
export { CmsDeployError } from './routes/deploy.js';
|
|
34
42
|
export { localEditors } from './auth/local-editors.js';
|
|
35
43
|
export { createEditorStore } from './auth/editors.js';
|
|
36
44
|
export { hashPassword, verifyPassword } from './auth/scrypt.js';
|
package/dist/backend/router.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 = {
|
|
@@ -11,13 +11,18 @@ import type { CmsStore } from '../store/queries.js';
|
|
|
11
11
|
import type { ReadCache } from '../store/cache.js';
|
|
12
12
|
import type { CmsResponders } from '../http.js';
|
|
13
13
|
import type { CmsStorage } from '../storage.js';
|
|
14
|
-
import type { CmsAuthAdapter, CmsAuthContext, CmsEditor } from '../types.js';
|
|
14
|
+
import type { CmsAuthAdapter, CmsAuthContext, CmsDeployAdapter, CmsEditor } from '../types.js';
|
|
15
15
|
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
|
|
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;
|
|
@@ -25,6 +30,8 @@ export type RouteCtx = {
|
|
|
25
30
|
respond: CmsResponders;
|
|
26
31
|
storage: CmsStorage;
|
|
27
32
|
auth: CmsAuthAdapter;
|
|
33
|
+
/** The host's deploy adapter, when configured. */
|
|
34
|
+
deploy?: CmsDeployAdapter;
|
|
28
35
|
authCtx: CmsAuthContext;
|
|
29
36
|
/** Absolute-or-root-relative URL for an uploaded file, per the host's
|
|
30
37
|
* configured media base. */
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type RouteCtx } from './context.js';
|
|
2
|
+
/** Thrown by an adapter to map a failure to a status: 409 while a run is in
|
|
3
|
+
* flight, 503 when the project has no pipeline to deploy through. */
|
|
4
|
+
export declare class CmsDeployError extends Error {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
constructor(status: number, message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare const getDeploy: (ctx: RouteCtx) => Promise<Response>;
|
|
9
|
+
export declare const postDeploy: (ctx: RouteCtx) => Promise<Response>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { requireUser } from './context.js';
|
|
2
|
+
/** Thrown by an adapter to map a failure to a status: 409 while a run is in
|
|
3
|
+
* flight, 503 when the project has no pipeline to deploy through. */
|
|
4
|
+
export class CmsDeployError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
constructor(status, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.name = 'CmsDeployError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const deployContext = (ctx) => ({
|
|
13
|
+
event: ctx.event,
|
|
14
|
+
projectId: ctx.projectId,
|
|
15
|
+
user: requireUser(ctx),
|
|
16
|
+
store: ctx.store
|
|
17
|
+
});
|
|
18
|
+
/** Adapter errors carry their own status; anything else is a bare 500 so a
|
|
19
|
+
* host bug never reaches the bar as an HTML error page it would try to parse. */
|
|
20
|
+
const respond = async (run, status) => {
|
|
21
|
+
try {
|
|
22
|
+
return Response.json(await run(), { status });
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
if (err instanceof CmsDeployError) {
|
|
26
|
+
return new Response(err.message, {
|
|
27
|
+
status: err.status,
|
|
28
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return new Response(null, { status: 500 });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
export const getDeploy = (ctx) => {
|
|
35
|
+
const { deploy } = ctx;
|
|
36
|
+
if (!deploy) {
|
|
37
|
+
return Promise.resolve(Response.json({ available: false }));
|
|
38
|
+
}
|
|
39
|
+
return respond(() => deploy.status(deployContext(ctx)), 200);
|
|
40
|
+
};
|
|
41
|
+
export const postDeploy = (ctx) => {
|
|
42
|
+
const { deploy } = ctx;
|
|
43
|
+
if (!deploy)
|
|
44
|
+
return Promise.resolve(new Response(null, { status: 404 }));
|
|
45
|
+
return respond(() => deploy.trigger(deployContext(ctx)), 202);
|
|
46
|
+
};
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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}`);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The route table.
|
|
3
3
|
*
|
|
4
|
-
* `auth` semantics:
|
|
5
|
-
* - `public`
|
|
6
|
-
* - `required` no editor is a 403
|
|
7
|
-
* - `exempt`
|
|
8
|
-
*
|
|
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[];
|
|
@@ -5,6 +5,7 @@ import { getUser, postLogout } from './user.js';
|
|
|
5
5
|
import { getLogin, getLoginSuccess, postLogin } from './login.js';
|
|
6
6
|
import { deleteReleaseItems, getRelease, getReleaseHistory, postReleaseDiscard, postReleaseHistoryPreviewKey, postReleaseItems, postReleasePreviewKey, postReleasePublish, postReleaseRevert } from './release.js';
|
|
7
7
|
import { deleteMediaItem, getMedia, getUpload, postMedia } from './media.js';
|
|
8
|
+
import { getDeploy, postDeploy } from './deploy.js';
|
|
8
9
|
import { postTestReset } from './test-reset.js';
|
|
9
10
|
export const ROUTES = [
|
|
10
11
|
{ method: 'GET', segments: ['docs'], auth: 'public', handler: getDocs },
|
|
@@ -62,6 +63,8 @@ export const ROUTES = [
|
|
|
62
63
|
auth: 'required',
|
|
63
64
|
handler: postReleaseHistoryPreviewKey
|
|
64
65
|
},
|
|
66
|
+
{ method: 'GET', segments: ['deploy'], auth: 'required', handler: getDeploy },
|
|
67
|
+
{ method: 'POST', segments: ['deploy'], auth: 'required', handler: postDeploy },
|
|
65
68
|
{ method: 'GET', segments: ['media'], auth: 'required', handler: getMedia },
|
|
66
69
|
{ method: 'POST', segments: ['media'], auth: 'required', handler: postMedia },
|
|
67
70
|
{ method: 'DELETE', segments: ['media', ':id'], auth: 'required', handler: deleteMediaItem },
|
|
@@ -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
|
|
12
|
-
*
|
|
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
|
|
15
|
-
*
|
|
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);
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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 ??
|
|
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) =>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
134
|
-
// is the same browser visiting a
|
|
135
|
-
// cross-project authorization
|
|
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
|
|
143
|
-
scoped.cookies.set(
|
|
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
|
package/dist/backend/types.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { RequestEvent } from '@sveltejs/kit';
|
|
2
|
+
import type { CmsDeployState } from '../core/wire.js';
|
|
3
|
+
import type { CmsStore } from './store/queries.js';
|
|
2
4
|
import type { SqliteDb } from './store/sqlite.js';
|
|
5
|
+
export type { CmsDeployRun, CmsDeployState } from '../core/wire.js';
|
|
3
6
|
/** An authenticated content editor. */
|
|
4
7
|
export type CmsEditor = {
|
|
5
8
|
id: string;
|
|
@@ -31,18 +34,57 @@ export type CmsAuthAdapter = {
|
|
|
31
34
|
/** Resolve the request's editor, or `null` for anonymous. Must not throw on
|
|
32
35
|
* a malformed or stale cookie: anonymous is a valid outcome, not an error. */
|
|
33
36
|
resolve(event: RequestEvent, ctx: CmsAuthContext): Promise<CmsEditor | null>;
|
|
34
|
-
/** May this editor act on this project? A `false`
|
|
35
|
-
*
|
|
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. */
|
|
36
40
|
authorize(user: CmsEditor, projectId: string, ctx: CmsAuthContext): Promise<boolean>;
|
|
37
41
|
/** Backs the built-in login page. Omit it and `/iframe/login` 404s, leaving
|
|
38
|
-
* 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. */
|
|
39
44
|
login?(email: string, password: string, ctx: CmsAuthContext): Promise<CmsSessionGrant | null>;
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
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. */
|
|
42
53
|
logout?(event: RequestEvent, ctx: CmsAuthContext): Promise<void>;
|
|
43
54
|
};
|
|
55
|
+
/** Handed to every deploy adapter method. `store` is the CMS store, so the
|
|
56
|
+
* host can read which release is current (`store.getReleaseHistory`). */
|
|
57
|
+
export type CmsDeployContext = {
|
|
58
|
+
event: RequestEvent;
|
|
59
|
+
projectId: string;
|
|
60
|
+
user: CmsEditor;
|
|
61
|
+
store: CmsStore;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* The seam between the CMS and whatever rebuilds the site.
|
|
65
|
+
*
|
|
66
|
+
* A static site bakes published content in at build time, so a publish only
|
|
67
|
+
* reaches visitors once the site is built again. The host owns that pipeline;
|
|
68
|
+
* the CMS only exposes it: `status()` backs the admin bar's menu and dialog,
|
|
69
|
+
* `trigger()` starts a run. Throw `CmsDeployError(409, …)` while a run is in
|
|
70
|
+
* flight and `CmsDeployError(503, …)` when the project has nothing to deploy to.
|
|
71
|
+
*/
|
|
72
|
+
export type CmsDeployAdapter = {
|
|
73
|
+
/** Can this project be deployed from the bar, and what happened last. */
|
|
74
|
+
status(ctx: CmsDeployContext): Promise<CmsDeployState>;
|
|
75
|
+
/** Start a deploy; resolves with the new state. */
|
|
76
|
+
trigger(ctx: CmsDeployContext): Promise<CmsDeployState>;
|
|
77
|
+
};
|
|
44
78
|
export type CmsCookieOptions = {
|
|
45
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
|
+
*/
|
|
46
88
|
path?: string;
|
|
47
89
|
sameSite?: 'lax' | 'strict' | 'none';
|
|
48
90
|
secure?: boolean;
|
|
@@ -85,6 +127,10 @@ export type CmsBackendOptions = {
|
|
|
85
127
|
restParam?: string;
|
|
86
128
|
/** Identity and authorization. Defaults to `localEditors()`. */
|
|
87
129
|
auth?: CmsAuthAdapter;
|
|
130
|
+
/** Rebuilds and deploys the site from the admin bar. Omit it — the default —
|
|
131
|
+
* and the bar never shows the action, which is right for a same-origin
|
|
132
|
+
* mount where the site reads the CMS live. */
|
|
133
|
+
deploy?: CmsDeployAdapter;
|
|
88
134
|
cookie?: CmsCookieOptions;
|
|
89
135
|
/**
|
|
90
136
|
* Cross-origin access. `false` (the default) emits no CORS headers, which is
|
|
@@ -13,10 +13,13 @@
|
|
|
13
13
|
import type { CmsPayload, CmsScopeEntry } from '../cms/scope.js';
|
|
14
14
|
import CssRoot from './css-root.svelte';
|
|
15
15
|
import { adminBarTheme, type AdminBarTheme } from './theme.svelte.js';
|
|
16
|
+
import type { CmsDeployState } from '../../core/wire.js';
|
|
16
17
|
import DeletePageDialog, {
|
|
17
18
|
type DeletePageMode,
|
|
18
19
|
type RedirectTarget
|
|
19
20
|
} from './delete-page-dialog.svelte';
|
|
21
|
+
import DeployDialog from './deploy-dialog.svelte';
|
|
22
|
+
import { isDeploying } from './deploy-state.js';
|
|
20
23
|
import HistoryPanel from './history-panel.svelte';
|
|
21
24
|
import KeyboardShortcutsDialog from './keyboard-shortcuts-dialog.svelte';
|
|
22
25
|
import MediaPanel from './media-panel.svelte';
|
|
@@ -83,6 +86,7 @@
|
|
|
83
86
|
let shareLinkOpen = $state(false);
|
|
84
87
|
let shortcutsOpen = $state(false);
|
|
85
88
|
let siteSettingsOpen = $state(false);
|
|
89
|
+
let deployOpen = $state(false);
|
|
86
90
|
const anyPanelOpen = $derived(
|
|
87
91
|
seoOpen ||
|
|
88
92
|
historyOpen ||
|
|
@@ -93,7 +97,8 @@
|
|
|
93
97
|
chooserOpen ||
|
|
94
98
|
shareLinkOpen ||
|
|
95
99
|
shortcutsOpen ||
|
|
96
|
-
siteSettingsOpen
|
|
100
|
+
siteSettingsOpen ||
|
|
101
|
+
deployOpen
|
|
97
102
|
);
|
|
98
103
|
let publishing = $state(false);
|
|
99
104
|
let publishError = $state<string | null>(null);
|
|
@@ -145,6 +150,7 @@
|
|
|
145
150
|
shareLinkOpen = false;
|
|
146
151
|
shortcutsOpen = false;
|
|
147
152
|
siteSettingsOpen = false;
|
|
153
|
+
deployOpen = false;
|
|
148
154
|
newDialog = null;
|
|
149
155
|
newPageError = null;
|
|
150
156
|
duplicateSource = null;
|
|
@@ -625,6 +631,71 @@
|
|
|
625
631
|
}
|
|
626
632
|
};
|
|
627
633
|
|
|
634
|
+
// Live-site deploy. A static site only shows visitors what was published
|
|
635
|
+
// when it was last built; the host's deploy adapter rebuilds it. The
|
|
636
|
+
// backend answers `available: false` unless the host configured one, so a
|
|
637
|
+
// same-origin mount never shows the item; an older backend 404s and the
|
|
638
|
+
// state simply stays null.
|
|
639
|
+
const DEPLOY_POLL_MS = 5000;
|
|
640
|
+
const DEPLOY_POLL_LIMIT = 120; // ~10 minutes, then leave it to the next mount
|
|
641
|
+
let deployState = $state<CmsDeployState | null>(null);
|
|
642
|
+
let deploying = $state(false);
|
|
643
|
+
let deployError = $state<string | null>(null);
|
|
644
|
+
const deployAvailable = $derived(deployState?.available === true);
|
|
645
|
+
const deployInFlight = $derived(isDeploying(deployState));
|
|
646
|
+
|
|
647
|
+
const fetchDeployState = async () => {
|
|
648
|
+
try {
|
|
649
|
+
const res = await fetch(`${endpoint}/deploy`, { credentials: 'include' });
|
|
650
|
+
if (!res.ok) return;
|
|
651
|
+
deployState = (await res.json()) as CmsDeployState;
|
|
652
|
+
} catch {
|
|
653
|
+
// Network failure: keep whatever we last knew.
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
$effect(() => {
|
|
658
|
+
void fetchDeployState();
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// Poll while a run is in flight, dialog open or not; the interval is
|
|
662
|
+
// cleared when the run finishes or the bar unmounts.
|
|
663
|
+
$effect(() => {
|
|
664
|
+
if (!deployInFlight) return;
|
|
665
|
+
let ticks = 0;
|
|
666
|
+
const id = setInterval(() => {
|
|
667
|
+
if (++ticks > DEPLOY_POLL_LIMIT) {
|
|
668
|
+
clearInterval(id);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
void fetchDeployState();
|
|
672
|
+
}, DEPLOY_POLL_MS);
|
|
673
|
+
return () => clearInterval(id);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const onOpenDeploy = () => {
|
|
677
|
+
if (!deployAvailable || deployInFlight) return;
|
|
678
|
+
closeAllPanels();
|
|
679
|
+
deployError = null;
|
|
680
|
+
deployOpen = true;
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
const onConfirmDeploy = async () => {
|
|
684
|
+
deploying = true;
|
|
685
|
+
deployError = null;
|
|
686
|
+
try {
|
|
687
|
+
const res = await fetch(`${endpoint}/deploy`, { method: 'POST', credentials: 'include' });
|
|
688
|
+
if (!res.ok) {
|
|
689
|
+
deployError = (await res.text()) || 'Could not start the deploy.';
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
deployState = (await res.json()) as CmsDeployState;
|
|
693
|
+
deployOpen = false;
|
|
694
|
+
} finally {
|
|
695
|
+
deploying = false;
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
|
|
628
699
|
const refreshAfterReleaseChange = async () => {
|
|
629
700
|
const key = cmsStore.openRelease?.preview_key ?? null;
|
|
630
701
|
await setPreviewParam(key, { replace: true });
|
|
@@ -1350,6 +1421,17 @@
|
|
|
1350
1421
|
Discard All Changes…
|
|
1351
1422
|
</Menubar.Item>
|
|
1352
1423
|
|
|
1424
|
+
{#if deployAvailable}
|
|
1425
|
+
<Menubar.Label class={menuLabelClass}>Live site</Menubar.Label>
|
|
1426
|
+
<Menubar.Item
|
|
1427
|
+
class={menuItemClass}
|
|
1428
|
+
onSelect={onOpenDeploy}
|
|
1429
|
+
disabled={deployInFlight}
|
|
1430
|
+
>
|
|
1431
|
+
{deployInFlight ? 'Deploying…' : 'Deploy Site…'}
|
|
1432
|
+
</Menubar.Item>
|
|
1433
|
+
{/if}
|
|
1434
|
+
|
|
1353
1435
|
<Menubar.Label class={menuLabelClass}>History</Menubar.Label>
|
|
1354
1436
|
<Menubar.Item class={menuItemClass} onSelect={onOpenHistory}>
|
|
1355
1437
|
Recent Releases
|
|
@@ -1688,6 +1770,18 @@
|
|
|
1688
1770
|
onRegenerate={onRegeneratePreviewKey}
|
|
1689
1771
|
/>
|
|
1690
1772
|
|
|
1773
|
+
{#if deployState?.available}
|
|
1774
|
+
<DeployDialog
|
|
1775
|
+
open={deployOpen}
|
|
1776
|
+
onOpenChange={(next) => (deployOpen = next)}
|
|
1777
|
+
deploy={deployState}
|
|
1778
|
+
unpublishedCount={counts.total}
|
|
1779
|
+
{deploying}
|
|
1780
|
+
error={deployError}
|
|
1781
|
+
onConfirm={onConfirmDeploy}
|
|
1782
|
+
/>
|
|
1783
|
+
{/if}
|
|
1784
|
+
|
|
1691
1785
|
<KeyboardShortcutsDialog open={shortcutsOpen} onOpenChange={(next) => (shortcutsOpen = next)} />
|
|
1692
1786
|
</div>
|
|
1693
1787
|
</CssRoot>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { CmsDeployState } from '../../core/wire.js';
|
|
3
|
+
import { describeLatestRun, isDeploying } from './deploy-state.js';
|
|
4
|
+
import { Button } from './ui/button/index.js';
|
|
5
|
+
import * as Dialog from './ui/dialog/index.js';
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
open: boolean;
|
|
9
|
+
onOpenChange: (open: boolean) => void;
|
|
10
|
+
deploy: CmsDeployState;
|
|
11
|
+
/** Items in the working copy; they are not part of a deploy. */
|
|
12
|
+
unpublishedCount: number;
|
|
13
|
+
/** The POST is in flight. */
|
|
14
|
+
deploying: boolean;
|
|
15
|
+
error: string | null;
|
|
16
|
+
onConfirm: () => void;
|
|
17
|
+
};
|
|
18
|
+
let { open, onOpenChange, deploy, unpublishedCount, deploying, error, onConfirm }: Props =
|
|
19
|
+
$props();
|
|
20
|
+
|
|
21
|
+
const inFlight = $derived(isDeploying(deploy));
|
|
22
|
+
const status = $derived(describeLatestRun(deploy.latest));
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<Dialog.Root
|
|
26
|
+
{open}
|
|
27
|
+
onOpenChange={(next) => {
|
|
28
|
+
if (!next && deploying) return;
|
|
29
|
+
onOpenChange(next);
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
<Dialog.Content class="vela:max-w-[28rem]">
|
|
33
|
+
<Dialog.Header>
|
|
34
|
+
<Dialog.Title>Deploy site</Dialog.Title>
|
|
35
|
+
<Dialog.Description>
|
|
36
|
+
Rebuilds the site with the latest published content and puts it live at
|
|
37
|
+
<span class="vela:font-mono vela:text-bar-text">{deploy.site?.url ?? 'the live site'}</span
|
|
38
|
+
>. Visitors only see published changes after a deploy.
|
|
39
|
+
</Dialog.Description>
|
|
40
|
+
</Dialog.Header>
|
|
41
|
+
|
|
42
|
+
<div class="vela:px-6 vela:pb-4 vela:flex vela:flex-col vela:gap-3">
|
|
43
|
+
<p class="vela:m-0 vela:text-[12px] vela:text-bar-text-secondary">{status}</p>
|
|
44
|
+
|
|
45
|
+
{#if unpublishedCount > 0}
|
|
46
|
+
<p
|
|
47
|
+
class="vela:m-0 vela:px-3 vela:py-2 vela:rounded-md vela:text-[12px]
|
|
48
|
+
vela:bg-[var(--cms-status-warn-bg)] vela:text-[var(--cms-status-warn-text)]"
|
|
49
|
+
>
|
|
50
|
+
{unpublishedCount}
|
|
51
|
+
{unpublishedCount === 1 ? 'unpublished change' : 'unpublished changes'} in your working copy
|
|
52
|
+
will not be included. Publish first to put them live.
|
|
53
|
+
</p>
|
|
54
|
+
{/if}
|
|
55
|
+
|
|
56
|
+
{#if error}
|
|
57
|
+
<p
|
|
58
|
+
role="alert"
|
|
59
|
+
class="vela:m-0 vela:px-3 vela:py-2 vela:rounded-md vela:text-[12px]
|
|
60
|
+
vela:bg-[var(--cms-status-error-bg)] vela:text-[var(--cms-status-error-text)]"
|
|
61
|
+
>
|
|
62
|
+
{error}
|
|
63
|
+
</p>
|
|
64
|
+
{/if}
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<Dialog.Footer class="vela:justify-end vela:pt-2">
|
|
68
|
+
<Button variant="ghost" onclick={() => onOpenChange(false)} disabled={deploying}
|
|
69
|
+
>Cancel</Button
|
|
70
|
+
>
|
|
71
|
+
<Button variant="default" onclick={onConfirm} disabled={deploying || inFlight}>
|
|
72
|
+
{deploying ? 'Deploying…' : 'Deploy'}
|
|
73
|
+
</Button>
|
|
74
|
+
</Dialog.Footer>
|
|
75
|
+
</Dialog.Content>
|
|
76
|
+
</Dialog.Root>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CmsDeployState } from '../../core/wire.js';
|
|
2
|
+
type Props = {
|
|
3
|
+
open: boolean;
|
|
4
|
+
onOpenChange: (open: boolean) => void;
|
|
5
|
+
deploy: CmsDeployState;
|
|
6
|
+
/** Items in the working copy; they are not part of a deploy. */
|
|
7
|
+
unpublishedCount: number;
|
|
8
|
+
/** The POST is in flight. */
|
|
9
|
+
deploying: boolean;
|
|
10
|
+
error: string | null;
|
|
11
|
+
onConfirm: () => void;
|
|
12
|
+
};
|
|
13
|
+
declare const DeployDialog: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type DeployDialog = ReturnType<typeof DeployDialog>;
|
|
15
|
+
export default DeployDialog;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the bar shows for a site deploy, kept out of the component so it can be
|
|
3
|
+
* unit tested: whether a run is in flight, and the one-line status of the last.
|
|
4
|
+
*/
|
|
5
|
+
import type { CmsDeployRun, CmsDeployState } from '../../core/wire.js';
|
|
6
|
+
export declare const isDeploying: (state: CmsDeployState | null) => boolean;
|
|
7
|
+
export declare const describeLatestRun: (latest: CmsDeployRun | null | undefined, now?: number) => string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { formatRelative } from './format-relative.js';
|
|
2
|
+
export const isDeploying = (state) => state?.latest?.status === 'pending' || state?.latest?.status === 'building';
|
|
3
|
+
export const describeLatestRun = (latest, now = Date.now()) => {
|
|
4
|
+
if (!latest)
|
|
5
|
+
return 'Never deployed';
|
|
6
|
+
if (latest.status === 'pending' || latest.status === 'building')
|
|
7
|
+
return 'Deploying…';
|
|
8
|
+
const when = formatRelative(latest.finishedAt ?? latest.createdAt, now);
|
|
9
|
+
if (latest.status === 'failed') {
|
|
10
|
+
return latest.error ? `Deploy failed ${when}: ${latest.error}` : `Deploy failed ${when}`;
|
|
11
|
+
}
|
|
12
|
+
return `Deployed ${when}`;
|
|
13
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A timestamp as the bar speaks about it: "just now", "5 minutes ago", "this
|
|
3
|
+
* afternoon", "yesterday", then a short date. Shared by the publish and
|
|
4
|
+
* deploy dialogs; `now` is a parameter so tests can pin it.
|
|
5
|
+
*/
|
|
6
|
+
export declare const formatRelative: (iso: string, now?: number) => string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A timestamp as the bar speaks about it: "just now", "5 minutes ago", "this
|
|
3
|
+
* afternoon", "yesterday", then a short date. Shared by the publish and
|
|
4
|
+
* deploy dialogs; `now` is a parameter so tests can pin it.
|
|
5
|
+
*/
|
|
6
|
+
export const formatRelative = (iso, now = Date.now()) => {
|
|
7
|
+
const t = new Date(iso).getTime();
|
|
8
|
+
if (Number.isNaN(t))
|
|
9
|
+
return '';
|
|
10
|
+
const seconds = Math.max(0, Math.floor((now - t) / 1000));
|
|
11
|
+
if (seconds < 45)
|
|
12
|
+
return 'just now';
|
|
13
|
+
const minutes = Math.floor(seconds / 60);
|
|
14
|
+
if (minutes < 60)
|
|
15
|
+
return minutes <= 1 ? '1 minute ago' : `${minutes} minutes ago`;
|
|
16
|
+
const d = new Date(t);
|
|
17
|
+
const today = new Date(now);
|
|
18
|
+
const sameDay = d.getFullYear() === today.getFullYear() &&
|
|
19
|
+
d.getMonth() === today.getMonth() &&
|
|
20
|
+
d.getDate() === today.getDate();
|
|
21
|
+
if (sameDay) {
|
|
22
|
+
const h = d.getHours();
|
|
23
|
+
if (h < 12)
|
|
24
|
+
return 'this morning';
|
|
25
|
+
if (h < 17)
|
|
26
|
+
return 'this afternoon';
|
|
27
|
+
return 'this evening';
|
|
28
|
+
}
|
|
29
|
+
const yesterday = new Date(today);
|
|
30
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
31
|
+
const isYesterday = d.getFullYear() === yesterday.getFullYear() &&
|
|
32
|
+
d.getMonth() === yesterday.getMonth() &&
|
|
33
|
+
d.getDate() === yesterday.getDate();
|
|
34
|
+
if (isYesterday)
|
|
35
|
+
return 'yesterday';
|
|
36
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
37
|
+
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { ReleaseItem } from '../cms/cms-store.svelte.js';
|
|
4
4
|
import type { CmsPayload } from '../cms/scope.js';
|
|
5
5
|
import { diffPaths, type Tree } from '../../core/path.js';
|
|
6
|
+
import { formatRelative } from './format-relative.js';
|
|
6
7
|
import { resolveRouteOnlyParams, resolveRouteUrl } from './resolve-route.js';
|
|
7
8
|
import { Badge, type BadgeVariant } from './ui/badge/index.js';
|
|
8
9
|
import { Button } from './ui/button/index.js';
|
|
@@ -229,36 +230,6 @@
|
|
|
229
230
|
return summarizeNames(meta, 'Updated');
|
|
230
231
|
};
|
|
231
232
|
|
|
232
|
-
const formatRelative = (iso: string): string => {
|
|
233
|
-
const t = new Date(iso).getTime();
|
|
234
|
-
if (Number.isNaN(t)) return '';
|
|
235
|
-
const now = Date.now();
|
|
236
|
-
const seconds = Math.max(0, Math.floor((now - t) / 1000));
|
|
237
|
-
if (seconds < 45) return 'just now';
|
|
238
|
-
const minutes = Math.floor(seconds / 60);
|
|
239
|
-
if (minutes < 60) return minutes <= 1 ? '1 minute ago' : `${minutes} minutes ago`;
|
|
240
|
-
const d = new Date(t);
|
|
241
|
-
const today = new Date(now);
|
|
242
|
-
const sameDay =
|
|
243
|
-
d.getFullYear() === today.getFullYear() &&
|
|
244
|
-
d.getMonth() === today.getMonth() &&
|
|
245
|
-
d.getDate() === today.getDate();
|
|
246
|
-
if (sameDay) {
|
|
247
|
-
const h = d.getHours();
|
|
248
|
-
if (h < 12) return 'this morning';
|
|
249
|
-
if (h < 17) return 'this afternoon';
|
|
250
|
-
return 'this evening';
|
|
251
|
-
}
|
|
252
|
-
const yesterday = new Date(today);
|
|
253
|
-
yesterday.setDate(yesterday.getDate() - 1);
|
|
254
|
-
const isYesterday =
|
|
255
|
-
d.getFullYear() === yesterday.getFullYear() &&
|
|
256
|
-
d.getMonth() === yesterday.getMonth() &&
|
|
257
|
-
d.getDate() === yesterday.getDate();
|
|
258
|
-
if (isYesterday) return 'yesterday';
|
|
259
|
-
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
260
|
-
};
|
|
261
|
-
|
|
262
233
|
/** Submit-disabled gate: nothing to publish when there are no items at all.
|
|
263
234
|
* (Group count is fine here too — no items → no groups.) */
|
|
264
235
|
const total = $derived(items.length);
|
package/dist/core/wire.d.ts
CHANGED
|
@@ -155,6 +155,23 @@ export type ListMediaResult = {
|
|
|
155
155
|
items: MediaItem[];
|
|
156
156
|
total: number;
|
|
157
157
|
};
|
|
158
|
+
/** One build-and-deploy of the site, as the host's deploy adapter reports it. */
|
|
159
|
+
export type CmsDeployRun = {
|
|
160
|
+
id: string;
|
|
161
|
+
status: 'pending' | 'building' | 'deployed' | 'failed';
|
|
162
|
+
createdAt: string;
|
|
163
|
+
finishedAt?: string;
|
|
164
|
+
error?: string;
|
|
165
|
+
};
|
|
166
|
+
/** `GET /deploy` and `POST /deploy`. `available` is `false` when the backend
|
|
167
|
+
* has no deploy adapter, which is how a same-origin mount hides the action. */
|
|
168
|
+
export type CmsDeployState = {
|
|
169
|
+
available: boolean;
|
|
170
|
+
site?: {
|
|
171
|
+
url: string;
|
|
172
|
+
};
|
|
173
|
+
latest?: CmsDeployRun | null;
|
|
174
|
+
};
|
|
158
175
|
export type DocsResponse = {
|
|
159
176
|
contents: Tree;
|
|
160
177
|
} | PageDeleteOutcome;
|
|
@@ -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
|
|
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
|