@shapio/client 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/errors-BXMW0adL.js +149 -0
- package/dist/errors-BXMW0adL.js.map +1 -0
- package/dist/index-BpSg5A3V.d.ts +2768 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1009 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +23 -0
- package/dist/node.js +62 -0
- package/dist/node.js.map +1 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
import { a as toQueryString, i as resolveUploadUrl, n as buildUploadForm, o as ADMIN_PATHS, r as createMediaApi, s as withId, t as ShapioApiError } from "./errors-BXMW0adL.js";
|
|
2
|
+
//#region src/admin/appUsers.ts
|
|
3
|
+
/** App users (Users → App users) and app roles (Settings → Roles → App roles). */
|
|
4
|
+
const createAppUsersApi = (request) => ({
|
|
5
|
+
appUsers: {
|
|
6
|
+
list: (query = {}) => request(`${ADMIN_PATHS.appUsers}${toQueryString(query)}`),
|
|
7
|
+
get: (id) => request(withId(ADMIN_PATHS.appUsers, id)),
|
|
8
|
+
/** Block/unblock and replace custom roles. Blocking takes effect on the user's next request. */
|
|
9
|
+
update: (id, body) => request(withId(ADMIN_PATHS.appUsers, id), {
|
|
10
|
+
method: "PATCH",
|
|
11
|
+
body
|
|
12
|
+
}),
|
|
13
|
+
remove: (id) => request(withId(ADMIN_PATHS.appUsers, id), { method: "DELETE" }),
|
|
14
|
+
/** 409 NOT_CONFIGURED without APP_AUTH_CONFIRM_EMAIL_URL; 409 ALREADY_CONFIRMED. */
|
|
15
|
+
resendConfirmation: (id) => request(`${withId(ADMIN_PATHS.appUsers, id)}/resend-confirmation`, { method: "POST" })
|
|
16
|
+
},
|
|
17
|
+
appRoles: {
|
|
18
|
+
list: () => request(ADMIN_PATHS.appRoles),
|
|
19
|
+
get: (id) => request(withId(ADMIN_PATHS.appRoles, id)),
|
|
20
|
+
create: (body) => request(ADMIN_PATHS.appRoles, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
body
|
|
23
|
+
}),
|
|
24
|
+
/** 409 VERSION_CONFLICT when `expectedVersion` is stale. */
|
|
25
|
+
update: (id, body) => request(withId(ADMIN_PATHS.appRoles, id), {
|
|
26
|
+
method: "PATCH",
|
|
27
|
+
body
|
|
28
|
+
}),
|
|
29
|
+
remove: (id) => request(withId(ADMIN_PATHS.appRoles, id), { method: "DELETE" })
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/admin/assist.ts
|
|
34
|
+
const ASSIST_PATHS = {
|
|
35
|
+
status: "/api/admin/assist/status",
|
|
36
|
+
altText: "/api/admin/assist/alt-text",
|
|
37
|
+
summarize: "/api/admin/assist/summarize",
|
|
38
|
+
translate: "/api/admin/assist/translate",
|
|
39
|
+
rewrite: "/api/admin/assist/rewrite",
|
|
40
|
+
schemaDraft: "/api/admin/assist/schema/draft",
|
|
41
|
+
contentOpsPropose: "/api/admin/assist/content-ops/propose",
|
|
42
|
+
contentOps: "/api/admin/assist/content-ops"
|
|
43
|
+
};
|
|
44
|
+
/** Editor assists with the instance's own model provider (documentation/assist.md). */
|
|
45
|
+
const createAssistApi = (request) => ({ assist: {
|
|
46
|
+
status: () => request(ASSIST_PATHS.status),
|
|
47
|
+
altText: (body) => request(ASSIST_PATHS.altText, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
body
|
|
50
|
+
}),
|
|
51
|
+
summarize: (body) => request(ASSIST_PATHS.summarize, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
body
|
|
54
|
+
}),
|
|
55
|
+
translate: (body) => request(ASSIST_PATHS.translate, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
body
|
|
58
|
+
}),
|
|
59
|
+
rewrite: (body) => request(ASSIST_PATHS.rewrite, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
body
|
|
62
|
+
}),
|
|
63
|
+
schemaDraft: (body) => request(ASSIST_PATHS.schemaDraft, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
body
|
|
66
|
+
}),
|
|
67
|
+
/** Starts a background run (202); poll it with `contentOpsRun`. */
|
|
68
|
+
proposeContentOps: (body) => request(ASSIST_PATHS.contentOpsPropose, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
body
|
|
71
|
+
}),
|
|
72
|
+
contentOpsRun: (runId) => request(withId(ASSIST_PATHS.contentOps, runId))
|
|
73
|
+
} });
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/admin/changeSets.ts
|
|
76
|
+
const setPath = (id) => withId(ADMIN_PATHS.changeSets, id);
|
|
77
|
+
const draftPath = (id, definitionId) => `${setPath(id)}/schema/${encodeURIComponent(definitionId)}`;
|
|
78
|
+
/** Change sets (schema + content shipped as one snapshot) and the snapshot ledger. */
|
|
79
|
+
const createChangeSetsApi = (request) => ({
|
|
80
|
+
changeSets: {
|
|
81
|
+
list: (query = {}) => request(`${ADMIN_PATHS.changeSets}${toQueryString(query)}`),
|
|
82
|
+
get: (id) => request(setPath(id)),
|
|
83
|
+
create: (body) => request(ADMIN_PATHS.changeSets, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
body
|
|
86
|
+
}),
|
|
87
|
+
update: (id, body) => request(setPath(id), {
|
|
88
|
+
method: "PATCH",
|
|
89
|
+
body
|
|
90
|
+
}),
|
|
91
|
+
/** Keeps the set as history (`discarded`) and deletes its schema drafts. */
|
|
92
|
+
discard: (id) => request(`${setPath(id)}/discard`, { method: "POST" }),
|
|
93
|
+
addEntry: (id, body) => request(`${setPath(id)}/items`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
body
|
|
96
|
+
}),
|
|
97
|
+
removeItem: (id, itemId) => request(`${setPath(id)}/items/${encodeURIComponent(itemId)}`, { method: "DELETE" }),
|
|
98
|
+
getSchemaDraft: (id, definitionId) => request(draftPath(id, definitionId)),
|
|
99
|
+
putSchemaDraft: (id, definitionId, body) => request(draftPath(id, definitionId), {
|
|
100
|
+
method: "PUT",
|
|
101
|
+
body
|
|
102
|
+
}),
|
|
103
|
+
removeSchemaDraft: (id, definitionId) => request(draftPath(id, definitionId), { method: "DELETE" }),
|
|
104
|
+
review: (id) => request(`${setPath(id)}/review`),
|
|
105
|
+
/**
|
|
106
|
+
* Resolves with the set: `shipped` (or `failed`) when it shipped inline, `shipping` (HTTP 202) when
|
|
107
|
+
* prerequisites run in the background. Poll `get` until it leaves `shipping`.
|
|
108
|
+
*/
|
|
109
|
+
ship: (id, body) => request(`${setPath(id)}/ship`, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
body
|
|
112
|
+
}),
|
|
113
|
+
schedule: (id, body) => request(`${setPath(id)}/schedule`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
body
|
|
116
|
+
}),
|
|
117
|
+
unschedule: (id) => request(`${setPath(id)}/unschedule`, { method: "POST" }),
|
|
118
|
+
timeline: async (id) => (await request(`${setPath(id)}/timeline`)).items,
|
|
119
|
+
unassigned: (query = {}) => request(`${ADMIN_PATHS.changeSets}/unassigned${toQueryString(query)}`)
|
|
120
|
+
},
|
|
121
|
+
snapshots: {
|
|
122
|
+
list: (query = {}) => request(`${ADMIN_PATHS.snapshots}${toQueryString(query)}`),
|
|
123
|
+
get: (seq) => request(`${ADMIN_PATHS.snapshots}/${seq}`),
|
|
124
|
+
/** Creates an open restore change set (reviewable); nothing goes live until it ships. */
|
|
125
|
+
restore: (seq) => request(`${ADMIN_PATHS.snapshots}/${seq}/restore`, { method: "POST" })
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/admin/contentQuery.ts
|
|
130
|
+
/**
|
|
131
|
+
* Serialises a content list query into the bracket querystring the content compiler parses
|
|
132
|
+
* (`filters[title][$eq]=A&sort=title:asc&page=2`). Lists repeat the key (`filters[id][$in]=a&filters[id][$in]=b`).
|
|
133
|
+
*/
|
|
134
|
+
const appendFilter = (params, prefix, value) => {
|
|
135
|
+
if (value === void 0 || value === null) return;
|
|
136
|
+
if (Array.isArray(value)) {
|
|
137
|
+
value.forEach((item, index) => {
|
|
138
|
+
if (typeof item === "object" && item !== null) appendFilter(params, `${prefix}[${index}]`, item);
|
|
139
|
+
else params.append(prefix, String(item));
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (typeof value === "object") {
|
|
144
|
+
for (const [key, child] of Object.entries(value)) appendFilter(params, `${prefix}[${key}]`, child);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") params.append(prefix, String(value));
|
|
148
|
+
};
|
|
149
|
+
const toContentQueryString = (query = {}) => {
|
|
150
|
+
const params = new URLSearchParams();
|
|
151
|
+
if (query.filters) appendFilter(params, "filters", query.filters);
|
|
152
|
+
for (const term of query.sort ?? []) params.append("sort", `${term.field}:${term.direction}`);
|
|
153
|
+
if (query.page !== void 0) params.set("page", String(query.page));
|
|
154
|
+
if (query.pageSize !== void 0) params.set("pageSize", String(query.pageSize));
|
|
155
|
+
if (query.q) params.set("q", query.q);
|
|
156
|
+
if (query.locale) params.set("locale", query.locale);
|
|
157
|
+
for (const field of query.fields ?? []) params.append("fields", field);
|
|
158
|
+
for (const path of query.populate ?? []) params.append("populate", path);
|
|
159
|
+
if (query.status) params.set("status", query.status);
|
|
160
|
+
if (query.author) params.set("author", query.author);
|
|
161
|
+
const serialised = params.toString();
|
|
162
|
+
return serialised ? `?${serialised}` : "";
|
|
163
|
+
};
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/admin/content.ts
|
|
166
|
+
const modelPath = (modelKey) => withId(ADMIN_PATHS.content, modelKey);
|
|
167
|
+
const entryPath$1 = (modelKey, id) => withId(modelPath(modelKey), id);
|
|
168
|
+
const revisionsPath = (modelKey, id) => `${entryPath$1(modelKey, id)}/revisions`;
|
|
169
|
+
/**
|
|
170
|
+
* Content of any model through the generic admin routes (`/api/admin/content/:modelKey`, where `modelKey`
|
|
171
|
+
* is the model's singular API ID for collections too; only the delivery API uses the plural). Drafts are read
|
|
172
|
+
* and written here; publishing is per locale. Writes carry the draft `version` the editor saw and fail with
|
|
173
|
+
* 409 CONTENT_VERSION_CONFLICT (or SCHEMA_CHANGED) when it moved.
|
|
174
|
+
*/
|
|
175
|
+
const createContentApi = (request) => ({
|
|
176
|
+
content: {
|
|
177
|
+
list: (modelKey, query = {}) => request(`${modelPath(modelKey)}${toContentQueryString(query)}`),
|
|
178
|
+
get: (modelKey, id, { locale } = {}) => request(`${entryPath$1(modelKey, id)}${toQueryString({ locale })}`),
|
|
179
|
+
/** Fully validated (`required` included); 422 CONTENT_INVALID with `details.issues`. */
|
|
180
|
+
create: (modelKey, body) => request(modelPath(modelKey), {
|
|
181
|
+
method: "POST",
|
|
182
|
+
body
|
|
183
|
+
}),
|
|
184
|
+
/** Saves a patch of one locale's draft; `autosave` skips `required` and creates no revision. */
|
|
185
|
+
update: (modelKey, id, body) => request(entryPath$1(modelKey, id), {
|
|
186
|
+
method: "PUT",
|
|
187
|
+
body
|
|
188
|
+
}),
|
|
189
|
+
/** 409 ENTRY_REFERENCED with `details.referrers` while other entries point at it. */
|
|
190
|
+
remove: (modelKey, id) => request(entryPath$1(modelKey, id), { method: "DELETE" }),
|
|
191
|
+
duplicate: (modelKey, id) => request(`${entryPath$1(modelKey, id)}/duplicate`, { method: "POST" }),
|
|
192
|
+
publish: (modelKey, id, body = {}) => request(`${entryPath$1(modelKey, id)}/publish`, {
|
|
193
|
+
method: "POST",
|
|
194
|
+
body
|
|
195
|
+
}),
|
|
196
|
+
unpublish: (modelKey, id, body = {}) => request(`${entryPath$1(modelKey, id)}/unpublish`, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
body
|
|
199
|
+
}),
|
|
200
|
+
revisions: async (modelKey, id, { locale } = {}) => (await request(`${revisionsPath(modelKey, id)}${toQueryString({ locale })}`)).items,
|
|
201
|
+
revision: (modelKey, id, revisionId) => request(withId(revisionsPath(modelKey, id), revisionId)),
|
|
202
|
+
/** Brings an older revision back as the new draft of its locale (a new revision). */
|
|
203
|
+
restore: (modelKey, id, revisionId, expectedVersion) => request(`${withId(revisionsPath(modelKey, id), revisionId)}/restore`, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
body: { expectedVersion }
|
|
206
|
+
})
|
|
207
|
+
},
|
|
208
|
+
extensions: {
|
|
209
|
+
/** Custom field editors the project installed (`shapio.config` + `extensions/editors/*.js`). */
|
|
210
|
+
editors: () => request(ADMIN_PATHS.editorManifest) }
|
|
211
|
+
});
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region src/admin/editing.ts
|
|
214
|
+
const entryPath = (base, modelKey, id) => withId(withId(base, modelKey), id);
|
|
215
|
+
/** The entry document's endpoints: pre-flight, content health (the Inbox), presence and counts. */
|
|
216
|
+
const createEditingApi = (request) => ({
|
|
217
|
+
preflight: {
|
|
218
|
+
/** What publishing these locales would run into, without publishing (no hooks, no locks). */
|
|
219
|
+
run: (modelKey, id, body = {}) => request(`${entryPath(ADMIN_PATHS.content, modelKey, id)}/preflight`, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
body
|
|
222
|
+
}) },
|
|
223
|
+
contentHealth: {
|
|
224
|
+
list: (query = {}) => request(`${ADMIN_PATHS.contentHealth}${toQueryString(query)}`),
|
|
225
|
+
summary: () => request(`${ADMIN_PATHS.contentHealth}/summary`)
|
|
226
|
+
},
|
|
227
|
+
presence: {
|
|
228
|
+
/** Heartbeat (every 15s while the document is visible); returns who else has the entry open. */
|
|
229
|
+
heartbeat: (modelKey, id, body) => request(entryPath(ADMIN_PATHS.presence, modelKey, id), {
|
|
230
|
+
method: "PUT",
|
|
231
|
+
body
|
|
232
|
+
}),
|
|
233
|
+
entry: (modelKey, id) => request(entryPath(ADMIN_PATHS.presence, modelKey, id)),
|
|
234
|
+
/** Everyone editing an entry of the model (list rows). */
|
|
235
|
+
model: (modelKey) => request(withId(ADMIN_PATHS.presence, modelKey)),
|
|
236
|
+
leave: (modelKey, id, tabId) => request(`${entryPath(ADMIN_PATHS.presence, modelKey, id)}${toQueryString({ tabId })}`, { method: "DELETE" })
|
|
237
|
+
},
|
|
238
|
+
contentCounts: { list: () => request(ADMIN_PATHS.contentCounts) }
|
|
239
|
+
});
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/admin/publishing.ts
|
|
242
|
+
/** Jobs, scheduled publications, webhooks, deployment connections and runs, preview (package H). */
|
|
243
|
+
const createPublishingApi = (request) => ({
|
|
244
|
+
jobs: {
|
|
245
|
+
list: (query = {}) => request(`${ADMIN_PATHS.jobs}${toQueryString(query)}`),
|
|
246
|
+
summary: () => request(`${ADMIN_PATHS.jobs}/summary`),
|
|
247
|
+
get: (id) => request(withId(ADMIN_PATHS.jobs, id)),
|
|
248
|
+
/** Dead jobs only: runs again from attempt 1. 409 JOB_NOT_RETRYABLE otherwise. */
|
|
249
|
+
retry: (id) => request(`${withId(ADMIN_PATHS.jobs, id)}/retry`, { method: "POST" })
|
|
250
|
+
},
|
|
251
|
+
schedules: {
|
|
252
|
+
list: (query = {}) => request(`${ADMIN_PATHS.schedules}${toQueryString(query)}`),
|
|
253
|
+
create: (body) => request(ADMIN_PATHS.schedules, {
|
|
254
|
+
method: "POST",
|
|
255
|
+
body
|
|
256
|
+
}),
|
|
257
|
+
cancel: (id) => request(withId(ADMIN_PATHS.schedules, id), { method: "DELETE" })
|
|
258
|
+
},
|
|
259
|
+
webhooks: {
|
|
260
|
+
list: () => request(ADMIN_PATHS.webhooks),
|
|
261
|
+
events: async () => (await request(`${ADMIN_PATHS.webhooks}/events`)).items,
|
|
262
|
+
get: (id) => request(withId(ADMIN_PATHS.webhooks, id)),
|
|
263
|
+
create: (body) => request(ADMIN_PATHS.webhooks, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
body
|
|
266
|
+
}),
|
|
267
|
+
update: (id, body) => request(withId(ADMIN_PATHS.webhooks, id), {
|
|
268
|
+
method: "PATCH",
|
|
269
|
+
body
|
|
270
|
+
}),
|
|
271
|
+
remove: (id) => request(withId(ADMIN_PATHS.webhooks, id), { method: "DELETE" }),
|
|
272
|
+
rotateSecret: (id) => request(`${withId(ADMIN_PATHS.webhooks, id)}/rotate-secret`, { method: "POST" }),
|
|
273
|
+
/** Queues a `webhook.test` delivery; poll the delivery log for its outcome. */
|
|
274
|
+
test: (id) => request(`${withId(ADMIN_PATHS.webhooks, id)}/test`, { method: "POST" }),
|
|
275
|
+
deliveries: (id, query = {}) => request(`${withId(ADMIN_PATHS.webhooks, id)}/deliveries${toQueryString(query)}`),
|
|
276
|
+
/** Sends the same payload again as a new delivery. */
|
|
277
|
+
redeliver: (id, deliveryId) => request(`${withId(ADMIN_PATHS.webhooks, id)}/deliveries/${encodeURIComponent(deliveryId)}/redeliver`, { method: "POST" })
|
|
278
|
+
},
|
|
279
|
+
deployments: {
|
|
280
|
+
connections: {
|
|
281
|
+
list: () => request(ADMIN_PATHS.deploymentConnections),
|
|
282
|
+
get: (id) => request(withId(ADMIN_PATHS.deploymentConnections, id)),
|
|
283
|
+
create: (body) => request(ADMIN_PATHS.deploymentConnections, {
|
|
284
|
+
method: "POST",
|
|
285
|
+
body
|
|
286
|
+
}),
|
|
287
|
+
update: (id, body) => request(withId(ADMIN_PATHS.deploymentConnections, id), {
|
|
288
|
+
method: "PATCH",
|
|
289
|
+
body
|
|
290
|
+
}),
|
|
291
|
+
remove: (id) => request(withId(ADMIN_PATHS.deploymentConnections, id), { method: "DELETE" }),
|
|
292
|
+
test: (id) => request(`${withId(ADMIN_PATHS.deploymentConnections, id)}/test`, { method: "POST" }),
|
|
293
|
+
/** A manual run at the current publication snapshot. */
|
|
294
|
+
trigger: (id) => request(`${withId(ADMIN_PATHS.deploymentConnections, id)}/runs`, { method: "POST" })
|
|
295
|
+
},
|
|
296
|
+
runs: {
|
|
297
|
+
list: (query = {}) => request(`${ADMIN_PATHS.deploymentRuns}${toQueryString(query)}`),
|
|
298
|
+
get: (id) => request(withId(ADMIN_PATHS.deploymentRuns, id)),
|
|
299
|
+
/** A new run (linked by `retryOf`) at the current snapshot. */
|
|
300
|
+
retry: (id) => request(`${withId(ADMIN_PATHS.deploymentRuns, id)}/retry`, { method: "POST" })
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
preview: {
|
|
304
|
+
tokens: {
|
|
305
|
+
list: (query = {}) => request(`${ADMIN_PATHS.previewTokens}${toQueryString(query)}`),
|
|
306
|
+
create: (body) => request(ADMIN_PATHS.previewTokens, {
|
|
307
|
+
method: "POST",
|
|
308
|
+
body
|
|
309
|
+
}),
|
|
310
|
+
revoke: (id) => request(withId(ADMIN_PATHS.previewTokens, id), { method: "DELETE" })
|
|
311
|
+
},
|
|
312
|
+
/** The site's connections with a preview URL (the entry form's Preview pane). */
|
|
313
|
+
targets: () => request(`${ADMIN_PATHS.preview}/targets`),
|
|
314
|
+
/** The entry form's Preview action: a short-lived entry token and the rendered preview URL. */
|
|
315
|
+
open: (body) => request(`${ADMIN_PATHS.preview}/open`, {
|
|
316
|
+
method: "POST",
|
|
317
|
+
body
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region src/admin/schema.ts
|
|
323
|
+
const DEFINITION_PATHS = {
|
|
324
|
+
model: ADMIN_PATHS.models,
|
|
325
|
+
component: ADMIN_PATHS.components
|
|
326
|
+
};
|
|
327
|
+
/** Models (collections and singletons) and components share one set of endpoints under their own base. */
|
|
328
|
+
const createDefinitionApi = (request, category) => {
|
|
329
|
+
const base = DEFINITION_PATHS[category];
|
|
330
|
+
return {
|
|
331
|
+
list: async () => (await request(base)).items,
|
|
332
|
+
get: (id) => request(withId(base, id)),
|
|
333
|
+
/** Creates and activates a definition (`pending` when prerequisites must run first). */
|
|
334
|
+
create: (body) => request(base, {
|
|
335
|
+
method: "POST",
|
|
336
|
+
body
|
|
337
|
+
}),
|
|
338
|
+
/** Plan preview of a create: classification and impact, nothing written. */
|
|
339
|
+
planCreate: (definition) => request(`${base}/plan`, {
|
|
340
|
+
method: "POST",
|
|
341
|
+
body: { definition }
|
|
342
|
+
}),
|
|
343
|
+
/** Changes a definition; 409 SCHEMA_VERSION_CONFLICT when `expectedVersion` is stale. */
|
|
344
|
+
update: (id, body) => request(withId(base, id), {
|
|
345
|
+
method: "PUT",
|
|
346
|
+
body
|
|
347
|
+
}),
|
|
348
|
+
planUpdate: (id, body) => request(`${withId(base, id)}/plan`, {
|
|
349
|
+
method: "POST",
|
|
350
|
+
body
|
|
351
|
+
}),
|
|
352
|
+
/** Soft delete: entries are kept and reappear if the definition is applied again. */
|
|
353
|
+
remove: (id, expectedVersion) => request(`${withId(base, id)}${toQueryString({ expectedVersion })}`, { method: "DELETE" }),
|
|
354
|
+
revisions: async (id) => (await request(`${withId(base, id)}/revisions`)).items,
|
|
355
|
+
changes: async (id) => (await request(`${withId(base, id)}/changes`)).items
|
|
356
|
+
};
|
|
357
|
+
};
|
|
358
|
+
/** Schema-wide endpoints: the version summary, planned-change status, the read-only lock, locales. */
|
|
359
|
+
const createSchemaApi = (request) => ({
|
|
360
|
+
models: createDefinitionApi(request, "model"),
|
|
361
|
+
components: createDefinitionApi(request, "component"),
|
|
362
|
+
schema: {
|
|
363
|
+
/** The global schema version and every visible definition's version (cheap; poll it for changes). */
|
|
364
|
+
summary: () => request(ADMIN_PATHS.schema),
|
|
365
|
+
change: (changeId) => request(withId(ADMIN_PATHS.schemaChanges, changeId)),
|
|
366
|
+
/** Every definition in the pull format, with the schema version they come from (`shapio schema pull`). */
|
|
367
|
+
export: () => request(ADMIN_PATHS.schemaExport),
|
|
368
|
+
/** Applies definition files live through the change planner; refuses when the base moved (409). */
|
|
369
|
+
apply: (body) => request(ADMIN_PATHS.schemaApply, {
|
|
370
|
+
method: "POST",
|
|
371
|
+
body
|
|
372
|
+
}),
|
|
373
|
+
settings: () => request(ADMIN_PATHS.schemaSettings),
|
|
374
|
+
updateSettings: (body) => request(ADMIN_PATHS.schemaSettings, {
|
|
375
|
+
method: "PUT",
|
|
376
|
+
body
|
|
377
|
+
})
|
|
378
|
+
},
|
|
379
|
+
locales: {
|
|
380
|
+
list: async () => (await request(ADMIN_PATHS.locales)).items,
|
|
381
|
+
create: (body) => request(ADMIN_PATHS.locales, {
|
|
382
|
+
method: "POST",
|
|
383
|
+
body
|
|
384
|
+
}),
|
|
385
|
+
update: (code, body) => request(withId(ADMIN_PATHS.locales, code), {
|
|
386
|
+
method: "PUT",
|
|
387
|
+
body
|
|
388
|
+
}),
|
|
389
|
+
/** A contract change: without `acknowledgeBreaking` the server answers 409 SCHEMA_CHANGE_NOT_ACKNOWLEDGED. */
|
|
390
|
+
setDefault: (code, acknowledgeBreaking) => request(`${withId(ADMIN_PATHS.locales, code)}/default`, {
|
|
391
|
+
method: "POST",
|
|
392
|
+
body: { acknowledgeBreaking }
|
|
393
|
+
}),
|
|
394
|
+
/**
|
|
395
|
+
* Deletes a locale and purges its content. When content exists and `acknowledgeDestructive` is false,
|
|
396
|
+
* the server answers 409 SCHEMA_CHANGE_NOT_ACKNOWLEDGED with `details.plan.impact.affectedHeads`.
|
|
397
|
+
*/
|
|
398
|
+
remove: (code, acknowledgeDestructive) => request(`${withId(ADMIN_PATHS.locales, code)}${toQueryString({ acknowledgeDestructive: acknowledgeDestructive || void 0 })}`, { method: "DELETE" })
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
//#endregion
|
|
402
|
+
//#region src/admin/sites.ts
|
|
403
|
+
const SITES_PATHS = { sites: "/api/admin/sites" };
|
|
404
|
+
/** The admin API header that names the site a site-scoped request is about (default: the primary site). */
|
|
405
|
+
const SITE_HEADER = "shapio-site";
|
|
406
|
+
const sitePath = (id) => `${SITES_PATHS.sites}/${encodeURIComponent(id)}`;
|
|
407
|
+
/** Sites (network routes): listing shows the sites the caller holds a role on; changes need `sites.manage`. */
|
|
408
|
+
const createSitesApi = (request) => ({ sites: {
|
|
409
|
+
list: () => request(SITES_PATHS.sites),
|
|
410
|
+
get: (id) => request(sitePath(id)),
|
|
411
|
+
create: (body) => request(SITES_PATHS.sites, {
|
|
412
|
+
method: "POST",
|
|
413
|
+
body
|
|
414
|
+
}),
|
|
415
|
+
update: (id, body) => request(sitePath(id), {
|
|
416
|
+
method: "PATCH",
|
|
417
|
+
body
|
|
418
|
+
}),
|
|
419
|
+
/** Only an empty site (409 `SITE_NOT_EMPTY`); never the primary site (409 `SITE_IS_PRIMARY`). */
|
|
420
|
+
remove: (id) => request(sitePath(id), { method: "DELETE" }),
|
|
421
|
+
/** The app roles bound to the site's anonymous callers (`public`) and signed-in app users (`authenticated`). */
|
|
422
|
+
appRoles: {
|
|
423
|
+
get: (id) => request(`${sitePath(id)}/app-roles`),
|
|
424
|
+
/** Replaces both bindings (`roles.manage`). */
|
|
425
|
+
set: (id, body) => request(`${sitePath(id)}/app-roles`, {
|
|
426
|
+
method: "PUT",
|
|
427
|
+
body
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
} });
|
|
431
|
+
//#endregion
|
|
432
|
+
//#region src/admin/usage.ts
|
|
433
|
+
const USAGE_PATHS = { fields: "/api/admin/usage/fields" };
|
|
434
|
+
/** Who reads which fields (needs `tokens.manage`). */
|
|
435
|
+
const createUsageApi = (request) => ({ usage: { fields: (query) => request(`${USAGE_PATHS.fields}${toQueryString(query)}`) } });
|
|
436
|
+
//#endregion
|
|
437
|
+
//#region src/admin/types.ts
|
|
438
|
+
const CONTENT_ACTIONS = [
|
|
439
|
+
"read",
|
|
440
|
+
"create",
|
|
441
|
+
"update",
|
|
442
|
+
"delete",
|
|
443
|
+
"publish",
|
|
444
|
+
"schemaManage"
|
|
445
|
+
];
|
|
446
|
+
/**
|
|
447
|
+
* Network actions: about the whole instance. Only roles assigned on every site grant them, so a role held
|
|
448
|
+
* on one site never reaches the network.
|
|
449
|
+
*/
|
|
450
|
+
const NETWORK_ACTIONS = [
|
|
451
|
+
"schema.create",
|
|
452
|
+
"users.manage",
|
|
453
|
+
"roles.manage",
|
|
454
|
+
"audit.read",
|
|
455
|
+
"sites.manage"
|
|
456
|
+
];
|
|
457
|
+
/** Site actions: about one site's library, tokens and publishing. */
|
|
458
|
+
const SITE_ACTIONS = [
|
|
459
|
+
"tokens.manage",
|
|
460
|
+
"media.read",
|
|
461
|
+
"media.write",
|
|
462
|
+
"media.manage",
|
|
463
|
+
"publishing.manage",
|
|
464
|
+
"webhooks.manage",
|
|
465
|
+
"deployments.manage",
|
|
466
|
+
"deployments.trigger",
|
|
467
|
+
"changes.manage",
|
|
468
|
+
"changes.ship"
|
|
469
|
+
];
|
|
470
|
+
/** Actions not scoped to a model: network actions and site actions. */
|
|
471
|
+
const GLOBAL_ACTIONS = [
|
|
472
|
+
"schema.create",
|
|
473
|
+
"users.manage",
|
|
474
|
+
"roles.manage",
|
|
475
|
+
"tokens.manage",
|
|
476
|
+
"audit.read",
|
|
477
|
+
"sites.manage",
|
|
478
|
+
"media.read",
|
|
479
|
+
"media.write",
|
|
480
|
+
"media.manage",
|
|
481
|
+
"publishing.manage",
|
|
482
|
+
"webhooks.manage",
|
|
483
|
+
"deployments.manage",
|
|
484
|
+
"deployments.trigger",
|
|
485
|
+
"changes.manage",
|
|
486
|
+
"changes.ship"
|
|
487
|
+
];
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region src/admin/publishingTypes.ts
|
|
490
|
+
const JOB_STATUSES = [
|
|
491
|
+
"pending",
|
|
492
|
+
"running",
|
|
493
|
+
"succeeded",
|
|
494
|
+
"dead"
|
|
495
|
+
];
|
|
496
|
+
const PUBLICATION_ACTIONS = ["publish", "unpublish"];
|
|
497
|
+
const SCHEDULE_STATUSES = [
|
|
498
|
+
"scheduled",
|
|
499
|
+
"done",
|
|
500
|
+
"failed",
|
|
501
|
+
"cancelled"
|
|
502
|
+
];
|
|
503
|
+
const DEPLOYMENT_PROVIDERS = [
|
|
504
|
+
"generic_webhook",
|
|
505
|
+
"cloudflare_pages",
|
|
506
|
+
"vercel",
|
|
507
|
+
"netlify",
|
|
508
|
+
"github"
|
|
509
|
+
];
|
|
510
|
+
const DEPLOYMENT_TRIGGERS = [
|
|
511
|
+
"publish",
|
|
512
|
+
"change_set",
|
|
513
|
+
"schema",
|
|
514
|
+
"manual"
|
|
515
|
+
];
|
|
516
|
+
const DEPLOYMENT_RUN_STATUSES = [
|
|
517
|
+
"queued",
|
|
518
|
+
"triggered",
|
|
519
|
+
"building",
|
|
520
|
+
"deployed",
|
|
521
|
+
"failed",
|
|
522
|
+
"unknown"
|
|
523
|
+
];
|
|
524
|
+
//#endregion
|
|
525
|
+
//#region src/admin/appUsersTypes.ts
|
|
526
|
+
/** What app roles may grant: content actions only. */
|
|
527
|
+
const APP_CONTENT_ACTIONS = [
|
|
528
|
+
"read",
|
|
529
|
+
"create",
|
|
530
|
+
"update",
|
|
531
|
+
"delete",
|
|
532
|
+
"publish"
|
|
533
|
+
];
|
|
534
|
+
/** Built-in app role keys: `public` applies to anonymous callers, `authenticated` to every app user. */
|
|
535
|
+
const BUILT_IN_APP_ROLE_KEYS = ["public", "authenticated"];
|
|
536
|
+
//#endregion
|
|
537
|
+
//#region src/admin/changeSetTypes.ts
|
|
538
|
+
/**
|
|
539
|
+
* Change sets and snapshots (developer-face plan §5), mirroring the TypeBox route schemas in
|
|
540
|
+
* apps/api/src/routes/admin/{changeSets,snapshots}/schemas.ts. Dates are ISO-8601 strings.
|
|
541
|
+
*/
|
|
542
|
+
const CHANGE_SET_STATUSES = [
|
|
543
|
+
"open",
|
|
544
|
+
"scheduled",
|
|
545
|
+
"shipping",
|
|
546
|
+
"shipped",
|
|
547
|
+
"failed",
|
|
548
|
+
"discarded"
|
|
549
|
+
];
|
|
550
|
+
const NOT_RESTORABLE_REASONS = [
|
|
551
|
+
"entry_deleted",
|
|
552
|
+
"model_missing",
|
|
553
|
+
"publishing_disabled",
|
|
554
|
+
"localization_changed",
|
|
555
|
+
"locale_missing",
|
|
556
|
+
"invalid",
|
|
557
|
+
"unique_conflict"
|
|
558
|
+
];
|
|
559
|
+
const SNAPSHOT_SOURCES = [
|
|
560
|
+
"publish",
|
|
561
|
+
"unpublish",
|
|
562
|
+
"delete",
|
|
563
|
+
"schedule",
|
|
564
|
+
"change_set",
|
|
565
|
+
"schema",
|
|
566
|
+
"conversion",
|
|
567
|
+
"import",
|
|
568
|
+
"legacy"
|
|
569
|
+
];
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region src/admin/editingTypes.ts
|
|
572
|
+
/**
|
|
573
|
+
* Shapes of the entry document's endpoints (editor experience, E0b): publish pre-flight, content health
|
|
574
|
+
* findings, presence and per-model counts. Sentences are never sent: the admin builds them from `rule` and
|
|
575
|
+
* `params` with its translations.
|
|
576
|
+
*/
|
|
577
|
+
/** Health rules evaluated per entry (`apps/api/src/services/contentHealth.ts`). */
|
|
578
|
+
const HEALTH_RULES = [
|
|
579
|
+
"requiredEmpty",
|
|
580
|
+
"altMissing",
|
|
581
|
+
"localeMissing",
|
|
582
|
+
"relationMissing",
|
|
583
|
+
"relationUnpublished",
|
|
584
|
+
"uniqueConflict",
|
|
585
|
+
"staleDraft",
|
|
586
|
+
"unpublishedChanges"
|
|
587
|
+
];
|
|
588
|
+
//#endregion
|
|
589
|
+
//#region src/admin/assistTypes.ts
|
|
590
|
+
const CONTENT_OPS_RULES = ["altMissing", "localeMissing"];
|
|
591
|
+
//#endregion
|
|
592
|
+
//#region src/admin/index.ts
|
|
593
|
+
/**
|
|
594
|
+
* Admin endpoints: identity (setup, auth, sessions, users, invitations, roles, tokens, audit) and the schema
|
|
595
|
+
* registry (models, components, schema, locales), the media library and content.
|
|
596
|
+
*/
|
|
597
|
+
const createAdminApi = (request) => ({
|
|
598
|
+
setup: {
|
|
599
|
+
status: () => request(ADMIN_PATHS.setup),
|
|
600
|
+
complete: (body) => request(ADMIN_PATHS.setup, {
|
|
601
|
+
method: "POST",
|
|
602
|
+
body
|
|
603
|
+
})
|
|
604
|
+
},
|
|
605
|
+
auth: {
|
|
606
|
+
csrf: () => request(ADMIN_PATHS.csrf),
|
|
607
|
+
login: (body) => request(ADMIN_PATHS.login, {
|
|
608
|
+
method: "POST",
|
|
609
|
+
body
|
|
610
|
+
}),
|
|
611
|
+
logout: () => request(ADMIN_PATHS.logout, { method: "POST" }),
|
|
612
|
+
me: () => request(ADMIN_PATHS.me),
|
|
613
|
+
updateProfile: (body) => request(ADMIN_PATHS.me, {
|
|
614
|
+
method: "PATCH",
|
|
615
|
+
body
|
|
616
|
+
}),
|
|
617
|
+
/** The session is rotated: the response carries the new session's CSRF token. */
|
|
618
|
+
changePassword: (body) => request(ADMIN_PATHS.password, {
|
|
619
|
+
method: "POST",
|
|
620
|
+
body
|
|
621
|
+
}),
|
|
622
|
+
requestPasswordReset: (body) => request(ADMIN_PATHS.passwordReset, {
|
|
623
|
+
method: "POST",
|
|
624
|
+
body
|
|
625
|
+
}),
|
|
626
|
+
confirmPasswordReset: (body) => request(ADMIN_PATHS.passwordResetConfirm, {
|
|
627
|
+
method: "POST",
|
|
628
|
+
body
|
|
629
|
+
})
|
|
630
|
+
},
|
|
631
|
+
sessions: {
|
|
632
|
+
list: () => request(ADMIN_PATHS.sessions),
|
|
633
|
+
revoke: (id) => request(withId(ADMIN_PATHS.sessions, id), { method: "DELETE" })
|
|
634
|
+
},
|
|
635
|
+
users: {
|
|
636
|
+
list: () => request(ADMIN_PATHS.users),
|
|
637
|
+
get: (id) => request(withId(ADMIN_PATHS.users, id)),
|
|
638
|
+
update: (id, body) => request(withId(ADMIN_PATHS.users, id), {
|
|
639
|
+
method: "PATCH",
|
|
640
|
+
body
|
|
641
|
+
}),
|
|
642
|
+
remove: (id) => request(withId(ADMIN_PATHS.users, id), { method: "DELETE" }),
|
|
643
|
+
revokeSessions: (id) => request(`${withId(ADMIN_PATHS.users, id)}/sessions`, { method: "DELETE" })
|
|
644
|
+
},
|
|
645
|
+
invitations: {
|
|
646
|
+
list: () => request(ADMIN_PATHS.invitations),
|
|
647
|
+
create: (body) => request(ADMIN_PATHS.invitations, {
|
|
648
|
+
method: "POST",
|
|
649
|
+
body
|
|
650
|
+
}),
|
|
651
|
+
revoke: (id) => request(withId(ADMIN_PATHS.invitations, id), { method: "DELETE" }),
|
|
652
|
+
/** A fresh link to send by hand; any earlier link for this invitation stops working. */
|
|
653
|
+
link: (id) => request(`${withId(ADMIN_PATHS.invitations, id)}/link`, { method: "POST" }),
|
|
654
|
+
/** Public: the token travels in the body, never in a URL the server logs. */
|
|
655
|
+
inspect: (token) => request(ADMIN_PATHS.inspectInvitation, {
|
|
656
|
+
method: "POST",
|
|
657
|
+
body: { token }
|
|
658
|
+
}),
|
|
659
|
+
accept: (body) => request(ADMIN_PATHS.acceptInvitation, {
|
|
660
|
+
method: "POST",
|
|
661
|
+
body
|
|
662
|
+
})
|
|
663
|
+
},
|
|
664
|
+
roles: {
|
|
665
|
+
list: () => request(ADMIN_PATHS.roles),
|
|
666
|
+
get: (id) => request(withId(ADMIN_PATHS.roles, id)),
|
|
667
|
+
create: (body) => request(ADMIN_PATHS.roles, {
|
|
668
|
+
method: "POST",
|
|
669
|
+
body
|
|
670
|
+
}),
|
|
671
|
+
update: (id, body) => request(withId(ADMIN_PATHS.roles, id), {
|
|
672
|
+
method: "PATCH",
|
|
673
|
+
body
|
|
674
|
+
}),
|
|
675
|
+
remove: (id) => request(withId(ADMIN_PATHS.roles, id), { method: "DELETE" })
|
|
676
|
+
},
|
|
677
|
+
tokens: {
|
|
678
|
+
list: () => request(ADMIN_PATHS.tokens),
|
|
679
|
+
create: (body) => request(ADMIN_PATHS.tokens, {
|
|
680
|
+
method: "POST",
|
|
681
|
+
body
|
|
682
|
+
}),
|
|
683
|
+
revoke: (id) => request(withId(ADMIN_PATHS.tokens, id), { method: "DELETE" })
|
|
684
|
+
},
|
|
685
|
+
audit: { list: (query = {}) => request(`${ADMIN_PATHS.audit}${toQueryString(query)}`) },
|
|
686
|
+
...createSchemaApi(request),
|
|
687
|
+
...createMediaApi(request),
|
|
688
|
+
...createContentApi(request),
|
|
689
|
+
...createEditingApi(request),
|
|
690
|
+
...createPublishingApi(request),
|
|
691
|
+
...createAppUsersApi(request),
|
|
692
|
+
...createChangeSetsApi(request),
|
|
693
|
+
...createUsageApi(request),
|
|
694
|
+
...createSitesApi(request),
|
|
695
|
+
...createAssistApi(request)
|
|
696
|
+
});
|
|
697
|
+
//#endregion
|
|
698
|
+
//#region src/appAuth/index.ts
|
|
699
|
+
const APP_AUTH_PATHS = {
|
|
700
|
+
register: "/api/app-auth/register",
|
|
701
|
+
login: "/api/app-auth/login",
|
|
702
|
+
refresh: "/api/app-auth/refresh",
|
|
703
|
+
logout: "/api/app-auth/logout",
|
|
704
|
+
me: "/api/app-auth/me",
|
|
705
|
+
password: "/api/app-auth/me/password",
|
|
706
|
+
confirmEmail: "/api/app-auth/confirm-email",
|
|
707
|
+
resendConfirmation: "/api/app-auth/confirm-email/resend",
|
|
708
|
+
passwordReset: "/api/app-auth/password-reset",
|
|
709
|
+
passwordResetConfirm: "/api/app-auth/password-reset/confirm",
|
|
710
|
+
providers: "/api/app-auth/providers",
|
|
711
|
+
oauthExchange: "/api/app-auth/oauth/exchange"
|
|
712
|
+
};
|
|
713
|
+
/**
|
|
714
|
+
* App-user endpoints. Calls that act as the signed-in user need a client created with `token: accessToken`.
|
|
715
|
+
* `baseUrl` is the client's base URL, used to build the OAuth start link the browser navigates to.
|
|
716
|
+
*/
|
|
717
|
+
const createAppAuthApi = (request, baseUrl) => ({
|
|
718
|
+
register: (body) => request(APP_AUTH_PATHS.register, {
|
|
719
|
+
method: "POST",
|
|
720
|
+
body
|
|
721
|
+
}),
|
|
722
|
+
login: (body) => request(APP_AUTH_PATHS.login, {
|
|
723
|
+
method: "POST",
|
|
724
|
+
body
|
|
725
|
+
}),
|
|
726
|
+
refresh: (refreshToken) => request(APP_AUTH_PATHS.refresh, {
|
|
727
|
+
method: "POST",
|
|
728
|
+
body: { refreshToken }
|
|
729
|
+
}),
|
|
730
|
+
/**
|
|
731
|
+
* Signs the account out everywhere: this login's refresh tokens are revoked, and every access token the
|
|
732
|
+
* account holds (on any device) is refused from the next request. Other logins recover with their own
|
|
733
|
+
* refresh token.
|
|
734
|
+
*/
|
|
735
|
+
logout: (refreshToken) => request(APP_AUTH_PATHS.logout, {
|
|
736
|
+
method: "POST",
|
|
737
|
+
body: { refreshToken }
|
|
738
|
+
}),
|
|
739
|
+
/** The signed-in user. Answers 401 once the account logged out anywhere, changed its password or was blocked. */
|
|
740
|
+
me: () => request(APP_AUTH_PATHS.me),
|
|
741
|
+
updateMe: (body) => request(APP_AUTH_PATHS.me, {
|
|
742
|
+
method: "PATCH",
|
|
743
|
+
body
|
|
744
|
+
}),
|
|
745
|
+
/** Returns a new session; every other sign-in of the account is revoked. */
|
|
746
|
+
changePassword: (body) => request(APP_AUTH_PATHS.password, {
|
|
747
|
+
method: "POST",
|
|
748
|
+
body
|
|
749
|
+
}),
|
|
750
|
+
deleteMe: (body = {}) => request(APP_AUTH_PATHS.me, {
|
|
751
|
+
method: "DELETE",
|
|
752
|
+
body
|
|
753
|
+
}),
|
|
754
|
+
/** The token from the `#token=` fragment of the confirmation link. */
|
|
755
|
+
confirmEmail: (token) => request(APP_AUTH_PATHS.confirmEmail, {
|
|
756
|
+
method: "POST",
|
|
757
|
+
body: { token }
|
|
758
|
+
}),
|
|
759
|
+
resendConfirmation: (email) => request(APP_AUTH_PATHS.resendConfirmation, {
|
|
760
|
+
method: "POST",
|
|
761
|
+
body: { email }
|
|
762
|
+
}),
|
|
763
|
+
requestPasswordReset: (email) => request(APP_AUTH_PATHS.passwordReset, {
|
|
764
|
+
method: "POST",
|
|
765
|
+
body: { email }
|
|
766
|
+
}),
|
|
767
|
+
confirmPasswordReset: (body) => request(APP_AUTH_PATHS.passwordResetConfirm, {
|
|
768
|
+
method: "POST",
|
|
769
|
+
body
|
|
770
|
+
}),
|
|
771
|
+
providers: async () => (await request(APP_AUTH_PATHS.providers)).providers,
|
|
772
|
+
/**
|
|
773
|
+
* Where to send the browser to sign in with a provider. `codeChallenge` comes from `createPkcePair()`;
|
|
774
|
+
* keep its `codeVerifier` until the browser returns. It comes back to `redirectTo` (an allowed return URL,
|
|
775
|
+
* by default an origin listed in CORS_ORIGINS) with `?code=` (pass it to `exchangeCode`) or `?error=`.
|
|
776
|
+
*/
|
|
777
|
+
oauthStartUrl: (provider, redirectTo, codeChallenge) => `${baseUrl.replace(/\/+$/, "")}/api/app-auth/oauth/${provider}/start?redirectTo=${encodeURIComponent(redirectTo)}&codeChallenge=${encodeURIComponent(codeChallenge)}`,
|
|
778
|
+
/** Trades the one-time code for a session. A wrong verifier spends the code: start the sign-in again. */
|
|
779
|
+
exchangeCode: (code, codeVerifier) => request(APP_AUTH_PATHS.oauthExchange, {
|
|
780
|
+
method: "POST",
|
|
781
|
+
body: {
|
|
782
|
+
code,
|
|
783
|
+
codeVerifier
|
|
784
|
+
}
|
|
785
|
+
})
|
|
786
|
+
});
|
|
787
|
+
//#endregion
|
|
788
|
+
//#region src/delivery.ts
|
|
789
|
+
const DELIVERY_PATH = "/api/content";
|
|
790
|
+
const routePath = (routeKey) => `${DELIVERY_PATH}/${encodeURIComponent(routeKey)}`;
|
|
791
|
+
/** The content query string plus `snapshot`, which only delivery reads take. */
|
|
792
|
+
const deliveryQueryString = ({ snapshot, ...query }) => {
|
|
793
|
+
const base = toContentQueryString(query);
|
|
794
|
+
if (snapshot === void 0) return base;
|
|
795
|
+
const pinned = `snapshot=${encodeURIComponent(String(snapshot))}`;
|
|
796
|
+
return base ? `${base}&${pinned}` : `?${pinned}`;
|
|
797
|
+
};
|
|
798
|
+
/**
|
|
799
|
+
* Published content, typed by the caller (`delivery.list<Article>('articles', { locale: 'fr', snapshot })`).
|
|
800
|
+
* A collection is read with `list` (a page) and `get` (one entry); a singleton with `singleton`.
|
|
801
|
+
*/
|
|
802
|
+
const createDeliveryApi = (request) => ({
|
|
803
|
+
list: (routeKey, query = {}) => request(`${routePath(routeKey)}${deliveryQueryString(query)}`),
|
|
804
|
+
singleton: (apiKey, query = {}) => request(`${routePath(apiKey)}${deliveryQueryString(query)}`),
|
|
805
|
+
get: (routeKey, id, query = {}) => request(`${routePath(routeKey)}/${encodeURIComponent(id)}${deliveryQueryString(query)}`)
|
|
806
|
+
});
|
|
807
|
+
//#endregion
|
|
808
|
+
//#region src/snapshots.ts
|
|
809
|
+
const SNAPSHOT_PATHS = {
|
|
810
|
+
current: "/api/snapshots/current",
|
|
811
|
+
changes: "/api/snapshots/changes"
|
|
812
|
+
};
|
|
813
|
+
/** Snapshots and the diff between two of them, for incremental site builds (delivery credentials). */
|
|
814
|
+
const createSnapshotsApi = (request) => {
|
|
815
|
+
const changes = (query) => request(`${SNAPSHOT_PATHS.changes}${toQueryString(query)}`);
|
|
816
|
+
return {
|
|
817
|
+
current: () => request(SNAPSHOT_PATHS.current),
|
|
818
|
+
changes,
|
|
819
|
+
/** Every change between `from` and `to`, following `nextCursor` until the last page. */
|
|
820
|
+
allChanges: async (query) => {
|
|
821
|
+
const items = [];
|
|
822
|
+
let page = await changes(query);
|
|
823
|
+
items.push(...page.items);
|
|
824
|
+
while (page.nextCursor !== null) {
|
|
825
|
+
page = await changes({
|
|
826
|
+
...query,
|
|
827
|
+
to: page.to,
|
|
828
|
+
after: page.nextCursor
|
|
829
|
+
});
|
|
830
|
+
items.push(...page.items);
|
|
831
|
+
}
|
|
832
|
+
return {
|
|
833
|
+
from: page.from,
|
|
834
|
+
to: page.to,
|
|
835
|
+
schemaVersions: page.schemaVersions,
|
|
836
|
+
items
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
};
|
|
841
|
+
//#endregion
|
|
842
|
+
//#region src/site.ts
|
|
843
|
+
/** Delivery reads name their site with `?site=`; everything else with the `Shapio-Site` header. */
|
|
844
|
+
const SITE_QUERY_PARAMETER = "site";
|
|
845
|
+
/** Delivery reads (content and snapshots): a GET there carries the site in its URL. */
|
|
846
|
+
const DELIVERY_READ_PREFIXES = [
|
|
847
|
+
`${DELIVERY_PATH}/`,
|
|
848
|
+
`${DELIVERY_PATH}?`,
|
|
849
|
+
"/api/snapshots/"
|
|
850
|
+
];
|
|
851
|
+
const isDeliveryRead = (method, path) => method === "GET" && (path === "/api/content" || Object.values(SNAPSHOT_PATHS).some((snapshotPath) => path === snapshotPath) || DELIVERY_READ_PREFIXES.some((prefix) => path.startsWith(prefix)));
|
|
852
|
+
const namesSite = (path) => {
|
|
853
|
+
const index = path.indexOf("?");
|
|
854
|
+
return index !== -1 && new URLSearchParams(path.slice(index + 1)).has("site");
|
|
855
|
+
};
|
|
856
|
+
/**
|
|
857
|
+
* Where a request names the client's site (sites plan §H). Delivery GETs put it in the query string: a
|
|
858
|
+
* simple cross-origin GET needs no CORS preflight, and URL-keyed CDN caches keep sites apart. Every other
|
|
859
|
+
* request sends the `Shapio-Site` header. A path that already names a site is left as it is.
|
|
860
|
+
*/
|
|
861
|
+
const applySite = (site, method, path) => {
|
|
862
|
+
if (site === void 0 || namesSite(path)) return {
|
|
863
|
+
path,
|
|
864
|
+
headers: {}
|
|
865
|
+
};
|
|
866
|
+
if (isDeliveryRead(method, path)) {
|
|
867
|
+
const parameter = `${SITE_QUERY_PARAMETER}=${encodeURIComponent(site)}`;
|
|
868
|
+
return {
|
|
869
|
+
path: `${path}${path.includes("?") ? "&" : "?"}${parameter}`,
|
|
870
|
+
headers: {}
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
return {
|
|
874
|
+
path,
|
|
875
|
+
headers: { [SITE_HEADER]: site }
|
|
876
|
+
};
|
|
877
|
+
};
|
|
878
|
+
//#endregion
|
|
879
|
+
//#region src/request.ts
|
|
880
|
+
const parseBody = async (response) => {
|
|
881
|
+
const text = await response.text();
|
|
882
|
+
if (text.length === 0) return;
|
|
883
|
+
try {
|
|
884
|
+
return JSON.parse(text);
|
|
885
|
+
} catch {
|
|
886
|
+
return text;
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
/** Builds the JSON request function every endpoint group uses. Non-2xx responses throw ShapioApiError. */
|
|
890
|
+
const createRequest = ({ baseUrl, token, fetch, credentials, headers, site }) => {
|
|
891
|
+
const origin = baseUrl.replace(/\/+$/, "");
|
|
892
|
+
return async (requestPath, { method = "GET", body, signal } = {}) => {
|
|
893
|
+
const { path, headers: siteHeaders } = applySite(site, method, requestPath);
|
|
894
|
+
const requestHeaders = {
|
|
895
|
+
accept: "application/json",
|
|
896
|
+
...siteHeaders,
|
|
897
|
+
...headers?.()
|
|
898
|
+
};
|
|
899
|
+
if (token) requestHeaders.authorization = `Bearer ${token}`;
|
|
900
|
+
if (body !== void 0) requestHeaders["content-type"] = "application/json";
|
|
901
|
+
const response = await fetch(`${origin}${path}`, {
|
|
902
|
+
method,
|
|
903
|
+
headers: requestHeaders,
|
|
904
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {},
|
|
905
|
+
...signal ? { signal } : {},
|
|
906
|
+
...credentials ? { credentials } : {}
|
|
907
|
+
});
|
|
908
|
+
const parsed = await parseBody(response);
|
|
909
|
+
if (!response.ok) throw new ShapioApiError(response.status, parsed);
|
|
910
|
+
return parsed;
|
|
911
|
+
};
|
|
912
|
+
};
|
|
913
|
+
//#endregion
|
|
914
|
+
//#region src/client.ts
|
|
915
|
+
const createClient = ({ baseUrl, token, fetch = globalThis.fetch, credentials, headers, site }) => {
|
|
916
|
+
const request = createRequest({
|
|
917
|
+
baseUrl,
|
|
918
|
+
token,
|
|
919
|
+
fetch,
|
|
920
|
+
credentials,
|
|
921
|
+
headers,
|
|
922
|
+
site
|
|
923
|
+
});
|
|
924
|
+
return {
|
|
925
|
+
/**
|
|
926
|
+
* Any Shapio route, e.g. the delivery API. Delivery paths use the model's route key: a collection's
|
|
927
|
+
* plural API ID (`request('/api/content/articles')`, `/api/content/articles/<id>`), a singleton's API ID
|
|
928
|
+
* (`/api/content/homepage`). The admin API (`admin.*`) always uses the singular API ID.
|
|
929
|
+
*/
|
|
930
|
+
request,
|
|
931
|
+
system: {
|
|
932
|
+
health: (signal) => request("/api/health", signal ? { signal } : {}),
|
|
933
|
+
ready: (signal) => request("/api/ready", signal ? { signal } : {}),
|
|
934
|
+
version: (signal) => request("/api/version", signal ? { signal } : {})
|
|
935
|
+
},
|
|
936
|
+
/** Published content (the delivery API), typed by the caller. */
|
|
937
|
+
delivery: createDeliveryApi(request),
|
|
938
|
+
admin: createAdminApi(request),
|
|
939
|
+
/** Publication snapshots and the diff between two (incremental builds). */
|
|
940
|
+
snapshots: createSnapshotsApi(request),
|
|
941
|
+
/** End users of your sites and apps: sign-up, sign-in, tokens, OAuth. */
|
|
942
|
+
appAuth: createAppAuthApi(request, baseUrl)
|
|
943
|
+
};
|
|
944
|
+
};
|
|
945
|
+
//#endregion
|
|
946
|
+
//#region src/webhooks.ts
|
|
947
|
+
/**
|
|
948
|
+
* Verifying Shapio's signed webhooks and deploy triggers on the receiving side (a site's revalidate route,
|
|
949
|
+
* a build hook). The scheme is the server's (`apps/api/src/publishing/signature.ts`):
|
|
950
|
+
* `X-Shapio-Signature: v1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>` with
|
|
951
|
+
* `X-Shapio-Timestamp: <unix seconds>`; several comma-separated `v1=` values are accepted (secret rotation).
|
|
952
|
+
* Uses Web Crypto, so it runs in Node, edge runtimes and browsers alike.
|
|
953
|
+
*/
|
|
954
|
+
const WEBHOOK_SIGNATURE_HEADER = "x-shapio-signature";
|
|
955
|
+
const WEBHOOK_TIMESTAMP_HEADER = "x-shapio-timestamp";
|
|
956
|
+
const WEBHOOK_EVENT_HEADER = "x-shapio-event";
|
|
957
|
+
/** Requests signed further from now than this are rejected (replay protection). */
|
|
958
|
+
const WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
959
|
+
const SIGNATURE_VERSION = "v1";
|
|
960
|
+
const toHex = (bytes) => Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
961
|
+
/** Compares two strings without returning early on the first difference. */
|
|
962
|
+
const constantTimeEqual = (a, b) => {
|
|
963
|
+
let difference = a.length ^ b.length;
|
|
964
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) difference |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0);
|
|
965
|
+
return difference === 0;
|
|
966
|
+
};
|
|
967
|
+
const expectedSignature = async (secret, timestamp, body) => {
|
|
968
|
+
const encoder = new TextEncoder();
|
|
969
|
+
const key = await globalThis.crypto.subtle.importKey("raw", encoder.encode(secret), {
|
|
970
|
+
name: "HMAC",
|
|
971
|
+
hash: "SHA-256"
|
|
972
|
+
}, false, ["sign"]);
|
|
973
|
+
const mac = await globalThis.crypto.subtle.sign("HMAC", key, encoder.encode(`${timestamp}.${body}`));
|
|
974
|
+
return `${SIGNATURE_VERSION}=${toHex(mac)}`;
|
|
975
|
+
};
|
|
976
|
+
/** Checks a Shapio signature; `{ ok: false, reason }` says why it was refused (never throws for bad input). */
|
|
977
|
+
const verifyWebhookSignature = async ({ secret, signature, timestamp, body, now = /* @__PURE__ */ new Date(), toleranceSeconds = 300 }) => {
|
|
978
|
+
if (!signature || !timestamp || !/^\d{1,12}$/.test(timestamp)) return {
|
|
979
|
+
ok: false,
|
|
980
|
+
reason: "missing signature or timestamp"
|
|
981
|
+
};
|
|
982
|
+
const seconds = Number(timestamp);
|
|
983
|
+
if (Math.abs(now.getTime() / 1e3 - seconds) > toleranceSeconds) return {
|
|
984
|
+
ok: false,
|
|
985
|
+
reason: "timestamp outside the allowed window"
|
|
986
|
+
};
|
|
987
|
+
const expected = await expectedSignature(secret, seconds, body);
|
|
988
|
+
return signature.split(",").map((candidate) => candidate.trim()).some((candidate) => constantTimeEqual(candidate, expected)) ? { ok: true } : {
|
|
989
|
+
ok: false,
|
|
990
|
+
reason: "signature mismatch"
|
|
991
|
+
};
|
|
992
|
+
};
|
|
993
|
+
//#endregion
|
|
994
|
+
//#region src/appAuth/pkce.ts
|
|
995
|
+
const toBase64Url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
996
|
+
/** The S256 challenge of a verifier: base64url(SHA-256(verifier)). */
|
|
997
|
+
const codeChallengeOf = async (codeVerifier) => toBase64Url(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier))));
|
|
998
|
+
/** A fresh verifier (32 random bytes, 43 characters) and its S256 challenge. */
|
|
999
|
+
const createPkcePair = async () => {
|
|
1000
|
+
const codeVerifier = toBase64Url(globalThis.crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)));
|
|
1001
|
+
return {
|
|
1002
|
+
codeVerifier,
|
|
1003
|
+
codeChallenge: await codeChallengeOf(codeVerifier)
|
|
1004
|
+
};
|
|
1005
|
+
};
|
|
1006
|
+
//#endregion
|
|
1007
|
+
export { ADMIN_PATHS, APP_AUTH_PATHS, APP_CONTENT_ACTIONS, ASSIST_PATHS, BUILT_IN_APP_ROLE_KEYS, CHANGE_SET_STATUSES, CONTENT_ACTIONS, CONTENT_OPS_RULES, DELIVERY_PATH, DEPLOYMENT_PROVIDERS, DEPLOYMENT_RUN_STATUSES, DEPLOYMENT_TRIGGERS, GLOBAL_ACTIONS, HEALTH_RULES, JOB_STATUSES, NETWORK_ACTIONS, NOT_RESTORABLE_REASONS, PUBLICATION_ACTIONS, SCHEDULE_STATUSES, SITES_PATHS, SITE_ACTIONS, SITE_HEADER, SITE_QUERY_PARAMETER, SNAPSHOT_SOURCES, ShapioApiError, WEBHOOK_EVENT_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER, WEBHOOK_TOLERANCE_SECONDS, buildUploadForm, codeChallengeOf, createClient, createPkcePair, resolveUploadUrl, toContentQueryString, verifyWebhookSignature };
|
|
1008
|
+
|
|
1009
|
+
//# sourceMappingURL=index.js.map
|