domma-cms 0.23.0 → 0.25.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/CLAUDE.md +14 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/crud-tutorial.js +1 -1
- package/admin/js/lib/project-context.js +1 -1
- package/admin/js/templates/api-endpoint-editor.html +120 -0
- package/admin/js/templates/api-endpoints.html +13 -0
- package/admin/js/templates/api-tokens.html +13 -0
- package/admin/js/templates/effects.html +752 -752
- package/admin/js/templates/form-submissions.html +30 -30
- package/admin/js/templates/forms.html +17 -17
- package/admin/js/templates/my-profile.html +17 -17
- package/admin/js/templates/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/views/api-endpoint-editor.js +1 -0
- package/admin/js/views/api-endpoints.js +7 -0
- package/admin/js/views/api-tokens.js +8 -0
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/project-detail.js +1 -1
- package/admin/js/views/roles.js +1 -1
- package/bin/lib/config-merge.js +44 -44
- package/bin/update.js +547 -547
- package/config/menus/admin-sidebar.json +13 -1
- package/package.json +1 -1
- package/server/middleware/auth.js +253 -253
- package/server/routes/api/api-endpoints.js +96 -0
- package/server/routes/api/api-tokens.js +83 -0
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/collections.js +114 -17
- package/server/routes/api/endpoints-public.js +88 -0
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/settings.js +141 -141
- package/server/routes/public.js +202 -202
- package/server/server.js +16 -1
- package/server/services/apiEndpoints.js +402 -0
- package/server/services/apiTokens.js +273 -0
- package/server/services/email.js +167 -167
- package/server/services/permissionRegistry.js +26 -0
- package/server/services/presetCollections.js +54 -0
- package/server/services/projects.js +18 -2
- package/server/services/roles.js +16 -0
- package/server/services/scaffolder.js +54 -1
- package/server/services/sidebar-migration.js +45 -0
- package/server/services/userProfiles.js +199 -199
- package/server/services/users.js +302 -302
- package/config/connections.json.bak +0 -9
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Endpoints
|
|
3
|
+
* User-defined, curated REST endpoints over collection data, served at
|
|
4
|
+
* `/api/x/<project><path>` by one catch-all route.
|
|
5
|
+
*
|
|
6
|
+
* Definitions are DATA, never code: entries in the file-based `api-endpoints`
|
|
7
|
+
* preset collection. A definition binds a URL path (with `:param` segments)
|
|
8
|
+
* to a collection query — fixed filters whose values may carry
|
|
9
|
+
* `{{params.<name>}}` / `{{query.<name>}}` placeholders, sort/limit, a read
|
|
10
|
+
* field allowlist, and an auth mode (public / token / role) that reuses the
|
|
11
|
+
* exact machinery of the public collections API.
|
|
12
|
+
*
|
|
13
|
+
* Substituted values only ever feed the filterEngine — there is no eval and
|
|
14
|
+
* no way for a definition to execute code.
|
|
15
|
+
*/
|
|
16
|
+
import {createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from './collections.js';
|
|
17
|
+
import {canSeeArtefact, getProject} from './projects.js';
|
|
18
|
+
import {getRoleMap} from './roles.js';
|
|
19
|
+
import {parseFilterKey} from './filterEngine.js';
|
|
20
|
+
import {hooks} from './hooks.js';
|
|
21
|
+
|
|
22
|
+
export const API_ENDPOINTS_COLLECTION_SLUG = 'api-endpoints';
|
|
23
|
+
|
|
24
|
+
const STATIC_SEGMENT_RE = /^[a-z0-9-]+$/;
|
|
25
|
+
const PARAM_SEGMENT_RE = /^:[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
26
|
+
const TEMPLATE_RE = /\{\{\s*(params|query)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
|
|
27
|
+
const MODES = ['list', 'single'];
|
|
28
|
+
const ORDERS = ['asc', 'desc'];
|
|
29
|
+
|
|
30
|
+
/** Thrown when a {{params.*}} placeholder cannot be resolved at request time. */
|
|
31
|
+
export class EndpointParamError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'EndpointParamError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Compiled registry: Map<project, compiled[]>; null = needs rebuild. */
|
|
39
|
+
let registry = null;
|
|
40
|
+
|
|
41
|
+
function invalidateRegistry() {
|
|
42
|
+
registry = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Generic admin collection endpoints emit these for ALL collections — the
|
|
46
|
+
// service's own mutations invalidate directly, this catches edits made
|
|
47
|
+
// through the admin entries grid.
|
|
48
|
+
for (const ev of ['collection:entryCreated', 'collection:entryUpdated', 'collection:entryDeleted']) {
|
|
49
|
+
hooks.on(ev, (payload) => {
|
|
50
|
+
if (payload?.slug === API_ENDPOINTS_COLLECTION_SLUG) invalidateRegistry();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Strip an entry down to the public definition shape.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} entry - Raw collection entry { id, data, meta }
|
|
58
|
+
* @returns {object}
|
|
59
|
+
*/
|
|
60
|
+
function sanitise(entry) {
|
|
61
|
+
const d = entry.data || {};
|
|
62
|
+
return {
|
|
63
|
+
id: entry.id,
|
|
64
|
+
name: d.name,
|
|
65
|
+
project: d.project,
|
|
66
|
+
path: d.path,
|
|
67
|
+
collection: d.collection,
|
|
68
|
+
auth: d.auth || 'public',
|
|
69
|
+
mode: MODES.includes(d.mode) ? d.mode : 'list',
|
|
70
|
+
filter: (d.filter && typeof d.filter === 'object') ? d.filter : {},
|
|
71
|
+
sort: d.sort || null,
|
|
72
|
+
order: ORDERS.includes(d.order) ? d.order : 'desc',
|
|
73
|
+
limit: Number.isInteger(d.limit) ? d.limit : 50,
|
|
74
|
+
fields: Array.isArray(d.fields) ? d.fields : [],
|
|
75
|
+
enabled: d.enabled !== false,
|
|
76
|
+
createdBy: d.createdBy || null,
|
|
77
|
+
meta: entry.meta
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Normalise a path to its shape for duplicate detection — every `:param`
|
|
83
|
+
* segment collapses to `:` so `/a/:x` and `/a/:y` are the same shape.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} path
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
function pathShape(path) {
|
|
89
|
+
return String(path).split('/').filter(Boolean)
|
|
90
|
+
.map(s => s.startsWith(':') ? ':' : s)
|
|
91
|
+
.join('/');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate a full endpoint definition. Throws on the first failure; the
|
|
96
|
+
* duplicate-shape error carries `code: 'DUPLICATE'` so routes can map it
|
|
97
|
+
* to 409.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} data
|
|
100
|
+
* @param {{excludeId?: string}} [opts] - Skip this entry id in the duplicate check (updates)
|
|
101
|
+
* @returns {Promise<void>}
|
|
102
|
+
*/
|
|
103
|
+
async function validateDefinition(data, {excludeId} = {}) {
|
|
104
|
+
if (!data.name || typeof data.name !== 'string' || !data.name.trim()) {
|
|
105
|
+
throw new Error('Endpoint name is required');
|
|
106
|
+
}
|
|
107
|
+
if (!data.project || typeof data.project !== 'string') throw new Error('Endpoint project is required');
|
|
108
|
+
if (!await getProject(data.project)) throw new Error(`Unknown project "${data.project}"`);
|
|
109
|
+
|
|
110
|
+
if (!data.collection || typeof data.collection !== 'string') throw new Error('Endpoint collection is required');
|
|
111
|
+
const schema = await getCollection(data.collection);
|
|
112
|
+
if (!schema) throw new Error(`Unknown collection "${data.collection}"`);
|
|
113
|
+
if (schema.systemManaged || schema.preset) {
|
|
114
|
+
throw new Error(`Collection "${data.collection}" is system-managed and cannot be exposed`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (typeof data.path !== 'string' || !data.path.startsWith('/')) {
|
|
118
|
+
throw new Error('Path must start with "/"');
|
|
119
|
+
}
|
|
120
|
+
const segments = data.path.split('/').filter(Boolean);
|
|
121
|
+
if (!segments.length) throw new Error('Path needs at least one segment');
|
|
122
|
+
const paramNames = new Set();
|
|
123
|
+
for (const seg of segments) {
|
|
124
|
+
if (PARAM_SEGMENT_RE.test(seg)) {
|
|
125
|
+
paramNames.add(seg.slice(1));
|
|
126
|
+
} else if (!STATIC_SEGMENT_RE.test(seg)) {
|
|
127
|
+
throw new Error(`Invalid path segment "${seg}" — use lowercase letters, numbers, hyphens, or :param`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const auth = data.auth || 'public';
|
|
132
|
+
if (auth !== 'public' && auth !== 'token' && !getRoleMap().has(auth)) {
|
|
133
|
+
throw new Error(`Unknown auth mode "${auth}" — use public, token, or a role name`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (data.mode != null && !MODES.includes(data.mode)) throw new Error('mode must be "list" or "single"');
|
|
137
|
+
if (data.order != null && !ORDERS.includes(data.order)) throw new Error('order must be "asc" or "desc"');
|
|
138
|
+
if (data.limit != null && (!Number.isInteger(data.limit) || data.limit < 0 || data.limit > 500)) {
|
|
139
|
+
throw new Error('limit must be an integer between 0 and 500');
|
|
140
|
+
}
|
|
141
|
+
if (data.fields != null && (!Array.isArray(data.fields) || data.fields.some(f => typeof f !== 'string'))) {
|
|
142
|
+
throw new Error('fields must be an array of field names');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (data.filter != null) {
|
|
146
|
+
if (typeof data.filter !== 'object' || Array.isArray(data.filter)) {
|
|
147
|
+
throw new Error('filter must be an object of { field_op: value }');
|
|
148
|
+
}
|
|
149
|
+
for (const [key, value] of Object.entries(data.filter)) {
|
|
150
|
+
const {field} = parseFilterKey(key);
|
|
151
|
+
if (!field) throw new Error(`Invalid filter key "${key}"`);
|
|
152
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
|
153
|
+
throw new Error(`Filter "${key}" must have a string, number, or boolean value`);
|
|
154
|
+
}
|
|
155
|
+
// Save-time check: every {{params.X}} must have a matching :X segment.
|
|
156
|
+
if (typeof value === 'string') {
|
|
157
|
+
for (const m of value.matchAll(TEMPLATE_RE)) {
|
|
158
|
+
if (m[1] === 'params' && !paramNames.has(m[2])) {
|
|
159
|
+
throw new Error(`Filter "${key}" references {{params.${m[2]}}} but the path has no :${m[2]} segment`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Duplicate shape per project — `/a/:x` and `/a/:y` collide.
|
|
167
|
+
const shape = pathShape(data.path);
|
|
168
|
+
const {entries} = await listEntries(API_ENDPOINTS_COLLECTION_SLUG, {limit: 0});
|
|
169
|
+
const clash = entries.find(e =>
|
|
170
|
+
e.id !== excludeId
|
|
171
|
+
&& e.data?.project === data.project
|
|
172
|
+
&& pathShape(e.data?.path || '') === shape
|
|
173
|
+
);
|
|
174
|
+
if (clash) {
|
|
175
|
+
const err = new Error(`An endpoint with the path shape "${data.path}" already exists in project "${data.project}" ("${clash.data.name}")`);
|
|
176
|
+
err.code = 'DUPLICATE';
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Create an endpoint definition.
|
|
183
|
+
*
|
|
184
|
+
* @param {object} input
|
|
185
|
+
* @returns {Promise<object>} Sanitised definition
|
|
186
|
+
* @throws {Error} Validation failure (code 'DUPLICATE' for path clashes)
|
|
187
|
+
*/
|
|
188
|
+
export async function createEndpoint(input) {
|
|
189
|
+
const data = {
|
|
190
|
+
name: String(input.name || '').trim(),
|
|
191
|
+
project: input.project,
|
|
192
|
+
path: input.path,
|
|
193
|
+
collection: input.collection,
|
|
194
|
+
auth: input.auth || 'public',
|
|
195
|
+
mode: input.mode || 'list',
|
|
196
|
+
filter: input.filter || {},
|
|
197
|
+
sort: input.sort || null,
|
|
198
|
+
order: input.order || 'desc',
|
|
199
|
+
limit: input.limit ?? 50,
|
|
200
|
+
fields: input.fields || [],
|
|
201
|
+
enabled: input.enabled !== false,
|
|
202
|
+
createdBy: input.createdBy || null
|
|
203
|
+
};
|
|
204
|
+
await validateDefinition(data);
|
|
205
|
+
const entry = await createEntry(API_ENDPOINTS_COLLECTION_SLUG, data, {createdBy: data.createdBy, source: 'admin'});
|
|
206
|
+
invalidateRegistry();
|
|
207
|
+
return sanitise(entry);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Update an endpoint. The project binding is immutable (it is the endpoint's
|
|
212
|
+
* URL namespace) — create a new endpoint to move one.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} id
|
|
215
|
+
* @param {object} patch
|
|
216
|
+
* @returns {Promise<object|null>}
|
|
217
|
+
*/
|
|
218
|
+
export async function updateEndpoint(id, patch = {}) {
|
|
219
|
+
const entry = await getEntry(API_ENDPOINTS_COLLECTION_SLUG, id);
|
|
220
|
+
if (!entry) return null;
|
|
221
|
+
|
|
222
|
+
const data = {...entry.data};
|
|
223
|
+
for (const key of ['name', 'path', 'collection', 'auth', 'mode', 'filter', 'sort', 'order', 'limit', 'fields']) {
|
|
224
|
+
if (patch[key] !== undefined) data[key] = patch[key];
|
|
225
|
+
}
|
|
226
|
+
if (patch.enabled !== undefined) data.enabled = patch.enabled !== false;
|
|
227
|
+
if (typeof data.name === 'string') data.name = data.name.trim();
|
|
228
|
+
|
|
229
|
+
await validateDefinition(data, {excludeId: id});
|
|
230
|
+
const updated = await updateEntry(API_ENDPOINTS_COLLECTION_SLUG, id, data);
|
|
231
|
+
invalidateRegistry();
|
|
232
|
+
return updated ? sanitise(updated) : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Delete an endpoint definition.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} id
|
|
239
|
+
* @returns {Promise<boolean>}
|
|
240
|
+
*/
|
|
241
|
+
export async function deleteEndpoint(id) {
|
|
242
|
+
const entry = await getEntry(API_ENDPOINTS_COLLECTION_SLUG, id);
|
|
243
|
+
if (!entry) return false;
|
|
244
|
+
await deleteEntry(API_ENDPOINTS_COLLECTION_SLUG, id);
|
|
245
|
+
invalidateRegistry();
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* List endpoints visible to the given admin user (project scope applies).
|
|
251
|
+
*
|
|
252
|
+
* @param {object} user
|
|
253
|
+
* @returns {Promise<object[]>}
|
|
254
|
+
*/
|
|
255
|
+
export async function listEndpointsSanitised(user) {
|
|
256
|
+
const {entries} = await listEntries(API_ENDPOINTS_COLLECTION_SLUG, {limit: 0});
|
|
257
|
+
return entries
|
|
258
|
+
.filter(e => canSeeArtefact(user, {meta: {project: e.data?.project}}))
|
|
259
|
+
.map(sanitise);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Fetch a single endpoint, sanitised. Caller does the canSeeArtefact check.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} id
|
|
266
|
+
* @returns {Promise<object|null>}
|
|
267
|
+
*/
|
|
268
|
+
export async function getEndpointSanitised(id) {
|
|
269
|
+
const entry = await getEntry(API_ENDPOINTS_COLLECTION_SLUG, id);
|
|
270
|
+
return entry ? sanitise(entry) : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Look up an endpoint by project + path SHAPE (`:x` ≡ `:y`).
|
|
275
|
+
* Used for scaffolder idempotency.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} project
|
|
278
|
+
* @param {string} path
|
|
279
|
+
* @returns {Promise<object|null>}
|
|
280
|
+
*/
|
|
281
|
+
export async function findEndpointByPath(project, path) {
|
|
282
|
+
const shape = pathShape(path);
|
|
283
|
+
const {entries} = await listEntries(API_ENDPOINTS_COLLECTION_SLUG, {limit: 0});
|
|
284
|
+
const entry = entries.find(e => e.data?.project === project && pathShape(e.data?.path || '') === shape);
|
|
285
|
+
return entry ? sanitise(entry) : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build the compiled registry from enabled definitions. Within a project,
|
|
290
|
+
* endpoints sort by static-segment count descending so static paths beat
|
|
291
|
+
* `:param` paths when both match.
|
|
292
|
+
*
|
|
293
|
+
* @returns {Promise<Map<string, object[]>>}
|
|
294
|
+
*/
|
|
295
|
+
async function buildRegistry() {
|
|
296
|
+
const {entries} = await listEntries(API_ENDPOINTS_COLLECTION_SLUG, {limit: 0});
|
|
297
|
+
const map = new Map();
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
const def = sanitise(entry);
|
|
300
|
+
if (!def.enabled || !def.project || !def.path) continue;
|
|
301
|
+
const segments = def.path.split('/').filter(Boolean).map(s =>
|
|
302
|
+
s.startsWith(':') ? {param: s.slice(1)} : {static: s}
|
|
303
|
+
);
|
|
304
|
+
const compiled = {
|
|
305
|
+
def,
|
|
306
|
+
segments,
|
|
307
|
+
staticCount: segments.filter(s => s.static).length
|
|
308
|
+
};
|
|
309
|
+
if (!map.has(def.project)) map.set(def.project, []);
|
|
310
|
+
map.get(def.project).push(compiled);
|
|
311
|
+
}
|
|
312
|
+
for (const list of map.values()) {
|
|
313
|
+
list.sort((a, b) => b.staticCount - a.staticCount);
|
|
314
|
+
}
|
|
315
|
+
return map;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Match a request path against the registry.
|
|
320
|
+
*
|
|
321
|
+
* @param {string} project - First wildcard segment
|
|
322
|
+
* @param {string[]} segments - Remaining wildcard segments (raw, URL-encoded)
|
|
323
|
+
* @returns {Promise<{def: object, params: Record<string, string>}|null>}
|
|
324
|
+
*/
|
|
325
|
+
export async function matchEndpoint(project, segments) {
|
|
326
|
+
if (!registry) registry = await buildRegistry();
|
|
327
|
+
const candidates = registry.get(project);
|
|
328
|
+
if (!candidates) return null;
|
|
329
|
+
|
|
330
|
+
outer:
|
|
331
|
+
for (const c of candidates) {
|
|
332
|
+
if (c.segments.length !== segments.length) continue;
|
|
333
|
+
const params = {};
|
|
334
|
+
for (let i = 0; i < c.segments.length; i++) {
|
|
335
|
+
const spec = c.segments[i];
|
|
336
|
+
if (spec.static !== undefined) {
|
|
337
|
+
if (spec.static !== segments[i]) continue outer;
|
|
338
|
+
} else {
|
|
339
|
+
try { params[spec.param] = decodeURIComponent(segments[i]); }
|
|
340
|
+
catch { params[spec.param] = segments[i]; }
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return {def: c.def, params};
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Substitute {{params.X}} / {{query.X}} placeholders in the definition's
|
|
350
|
+
* filter values. Unresolved params throw (route maps to 400); a clause whose
|
|
351
|
+
* {{query.X}} stays unresolved is DROPPED, so query placeholders act as
|
|
352
|
+
* optional refinements.
|
|
353
|
+
*
|
|
354
|
+
* @param {object} filterTemplate
|
|
355
|
+
* @param {Record<string, string>} params
|
|
356
|
+
* @param {Record<string, string>} query
|
|
357
|
+
* @returns {object} Concrete filter for the filterEngine
|
|
358
|
+
*/
|
|
359
|
+
function substituteFilter(filterTemplate, params, query) {
|
|
360
|
+
const out = {};
|
|
361
|
+
for (const [key, raw] of Object.entries(filterTemplate || {})) {
|
|
362
|
+
if (typeof raw !== 'string') { out[key] = raw; continue; }
|
|
363
|
+
|
|
364
|
+
let dropped = false;
|
|
365
|
+
const value = raw.replace(TEMPLATE_RE, (_m, kind, name) => {
|
|
366
|
+
const source = kind === 'params' ? params : query;
|
|
367
|
+
const v = source?.[name];
|
|
368
|
+
if (v === undefined || v === null || v === '') {
|
|
369
|
+
if (kind === 'params') {
|
|
370
|
+
throw new EndpointParamError(`Missing required path parameter "${name}"`);
|
|
371
|
+
}
|
|
372
|
+
dropped = true;
|
|
373
|
+
return '';
|
|
374
|
+
}
|
|
375
|
+
return String(v);
|
|
376
|
+
});
|
|
377
|
+
if (!dropped) out[key] = value;
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Execute a matched endpoint definition.
|
|
384
|
+
*
|
|
385
|
+
* @param {object} def
|
|
386
|
+
* @param {Record<string, string>} params - Decoded path params
|
|
387
|
+
* @param {Record<string, string>} query - Request query object
|
|
388
|
+
* @returns {Promise<object|null>} list payload, single entry, or null (single miss)
|
|
389
|
+
* @throws {EndpointParamError}
|
|
390
|
+
*/
|
|
391
|
+
export async function executeEndpoint(def, params, query) {
|
|
392
|
+
const filter = substituteFilter(def.filter, params, query || {});
|
|
393
|
+
const result = await listEntries(def.collection, {
|
|
394
|
+
page: 1,
|
|
395
|
+
limit: def.mode === 'single' ? 1 : (def.limit ?? 50),
|
|
396
|
+
sort: def.sort || 'createdAt',
|
|
397
|
+
order: def.order || 'desc',
|
|
398
|
+
filter: Object.keys(filter).length ? filter : undefined
|
|
399
|
+
});
|
|
400
|
+
if (def.mode === 'single') return result.entries[0] || null;
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Tokens
|
|
3
|
+
* Project-scoped machine credentials for the external collections API.
|
|
4
|
+
*
|
|
5
|
+
* Tokens are entries in the file-based `api-tokens` preset collection.
|
|
6
|
+
* Only a SHA-256 hash is stored — the plaintext (`dcms_<64 hex>`) is returned
|
|
7
|
+
* exactly once, from createToken(). A token is accepted only on collection
|
|
8
|
+
* verbs configured with `api.<verb>.access === 'token'`, and only for
|
|
9
|
+
* collections whose resolved project matches the token's `project` binding.
|
|
10
|
+
*/
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from './collections.js';
|
|
13
|
+
import {canSeeArtefact, getProject} from './projects.js';
|
|
14
|
+
import {hooks} from './hooks.js';
|
|
15
|
+
|
|
16
|
+
export const API_TOKENS_COLLECTION_SLUG = 'api-tokens';
|
|
17
|
+
|
|
18
|
+
const TOKEN_RE = /^dcms_[a-f0-9]{64}$/;
|
|
19
|
+
const VERBS = ['create', 'read', 'update', 'delete'];
|
|
20
|
+
const LAST_USED_THROTTLE_MS = 60_000;
|
|
21
|
+
|
|
22
|
+
/** In-memory hash → entry cache; null = needs rebuild. */
|
|
23
|
+
let tokenCache = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Serialises this service's fire-and-forget writes (the throttled lastUsedAt
|
|
27
|
+
* update) against its mutations. FileAdapter does unlocked read-modify-write,
|
|
28
|
+
* so an in-flight lastUsedAt update that read the data file BEFORE a revoke
|
|
29
|
+
* wrote it would write the revoked token straight back — resurrection.
|
|
30
|
+
* Mutations await the chain; lastUsedAt updates append to it.
|
|
31
|
+
*/
|
|
32
|
+
let writeChain = Promise.resolve();
|
|
33
|
+
|
|
34
|
+
function invalidateCache() {
|
|
35
|
+
tokenCache = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Generic admin collection endpoints emit these for ALL collections — the
|
|
39
|
+
// service's own mutations invalidate directly, this catches edits made
|
|
40
|
+
// through the admin entries grid.
|
|
41
|
+
for (const ev of ['collection:entryCreated', 'collection:entryUpdated', 'collection:entryDeleted']) {
|
|
42
|
+
hooks.on(ev, (payload) => {
|
|
43
|
+
if (payload?.slug === API_TOKENS_COLLECTION_SLUG) invalidateCache();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* SHA-256 hex digest of a plaintext token.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} plaintext
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
function hashToken(plaintext) {
|
|
54
|
+
return crypto.createHash('sha256').update(plaintext).digest('hex');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Strip the entry down to what callers may see. Never includes tokenHash.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} entry - Raw collection entry { id, data, meta }
|
|
61
|
+
* @returns {object}
|
|
62
|
+
*/
|
|
63
|
+
function sanitise(entry) {
|
|
64
|
+
const {name, project, tokenHint, scopes, enabled, expiresAt, lastUsedAt, createdBy} = entry.data || {};
|
|
65
|
+
return {
|
|
66
|
+
id: entry.id,
|
|
67
|
+
name, project, tokenHint,
|
|
68
|
+
scopes: Array.isArray(scopes) ? scopes : [],
|
|
69
|
+
enabled: enabled !== false,
|
|
70
|
+
expiresAt: expiresAt || null,
|
|
71
|
+
lastUsedAt: lastUsedAt || null,
|
|
72
|
+
createdBy: createdBy || null,
|
|
73
|
+
meta: entry.meta
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate a scopes declaration: array of { collection, verbs[] }.
|
|
79
|
+
* Empty array = the token covers every collection in its project.
|
|
80
|
+
*
|
|
81
|
+
* @param {*} scopes
|
|
82
|
+
* @returns {string|null} Error message, or null when valid
|
|
83
|
+
*/
|
|
84
|
+
function validateScopes(scopes) {
|
|
85
|
+
if (scopes == null) return null;
|
|
86
|
+
if (!Array.isArray(scopes)) return 'scopes must be an array';
|
|
87
|
+
for (const s of scopes) {
|
|
88
|
+
if (!s || typeof s !== 'object' || typeof s.collection !== 'string' || !s.collection.trim()) {
|
|
89
|
+
return 'each scope needs a collection slug';
|
|
90
|
+
}
|
|
91
|
+
if (s.verbs != null) {
|
|
92
|
+
if (!Array.isArray(s.verbs)) return 'scope verbs must be an array';
|
|
93
|
+
const bad = s.verbs.find(v => !VERBS.includes(v));
|
|
94
|
+
if (bad) return `unknown scope verb "${bad}"`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check whether a token's scopes permit an operation on a collection.
|
|
102
|
+
* Empty/missing scopes → everything in the token's project is allowed.
|
|
103
|
+
*
|
|
104
|
+
* @param {Array<{collection: string, verbs?: string[]}>} scopes
|
|
105
|
+
* @param {string} collectionSlug
|
|
106
|
+
* @param {string} verb - create | read | update | delete
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
export function scopeAllows(scopes, collectionSlug, verb) {
|
|
110
|
+
if (!Array.isArray(scopes) || scopes.length === 0) return true;
|
|
111
|
+
const scope = scopes.find(s => s.collection === collectionSlug);
|
|
112
|
+
if (!scope) return false;
|
|
113
|
+
return !Array.isArray(scope.verbs) || scope.verbs.length === 0 || scope.verbs.includes(verb);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a new token. The plaintext is returned ONCE here and never again.
|
|
118
|
+
*
|
|
119
|
+
* @param {{name: string, project: string, scopes?: object[], expiresAt?: string|null, createdBy?: string|null}} input
|
|
120
|
+
* @returns {Promise<{entry: object, plaintext: string}>} Sanitised entry + plaintext
|
|
121
|
+
* @throws {Error} On validation failure
|
|
122
|
+
*/
|
|
123
|
+
export async function createToken({name, project, scopes = [], expiresAt = null, createdBy = null}) {
|
|
124
|
+
if (!name || typeof name !== 'string' || !name.trim()) throw new Error('Token name is required');
|
|
125
|
+
if (!project || typeof project !== 'string') throw new Error('Token project is required');
|
|
126
|
+
if (!await getProject(project)) throw new Error(`Unknown project "${project}"`);
|
|
127
|
+
const scopeError = validateScopes(scopes);
|
|
128
|
+
if (scopeError) throw new Error(scopeError);
|
|
129
|
+
if (expiresAt != null && Number.isNaN(Date.parse(expiresAt))) throw new Error('expiresAt must be a valid date');
|
|
130
|
+
|
|
131
|
+
const plaintext = 'dcms_' + crypto.randomBytes(32).toString('hex');
|
|
132
|
+
const data = {
|
|
133
|
+
name: name.trim(),
|
|
134
|
+
project,
|
|
135
|
+
tokenHash: hashToken(plaintext),
|
|
136
|
+
tokenHint: plaintext.slice(-4),
|
|
137
|
+
scopes: scopes || [],
|
|
138
|
+
enabled: true,
|
|
139
|
+
expiresAt: expiresAt || null,
|
|
140
|
+
lastUsedAt: null,
|
|
141
|
+
createdBy
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const entry = await createEntry(API_TOKENS_COLLECTION_SLUG, data, {createdBy, source: 'admin'});
|
|
145
|
+
invalidateCache();
|
|
146
|
+
return {entry: sanitise(entry), plaintext};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Look up a token by name + project. Used for scaffolder idempotency.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} name
|
|
153
|
+
* @param {string} project
|
|
154
|
+
* @returns {Promise<object|null>} Sanitised entry or null
|
|
155
|
+
*/
|
|
156
|
+
export async function findTokenByName(name, project) {
|
|
157
|
+
const {entries} = await listEntries(API_TOKENS_COLLECTION_SLUG, {limit: 0});
|
|
158
|
+
const entry = entries.find(e => e.data?.name === name && e.data?.project === project);
|
|
159
|
+
return entry ? sanitise(entry) : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Validate a presented plaintext token.
|
|
164
|
+
* Returns the sanitised entry when the token exists, is enabled, and has not
|
|
165
|
+
* expired — otherwise null. Updates lastUsedAt at most once per minute
|
|
166
|
+
* (fire-and-forget; the cached copy is patched in place to avoid thrash).
|
|
167
|
+
*
|
|
168
|
+
* @param {string} plaintext
|
|
169
|
+
* @returns {Promise<object|null>}
|
|
170
|
+
*/
|
|
171
|
+
export async function validateToken(plaintext) {
|
|
172
|
+
if (typeof plaintext !== 'string' || !TOKEN_RE.test(plaintext)) return null;
|
|
173
|
+
const hash = hashToken(plaintext);
|
|
174
|
+
|
|
175
|
+
if (!tokenCache) {
|
|
176
|
+
const {entries} = await listEntries(API_TOKENS_COLLECTION_SLUG, {limit: 0});
|
|
177
|
+
tokenCache = new Map(entries.map(e => [e.data?.tokenHash, e]));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const entry = tokenCache.get(hash);
|
|
181
|
+
if (!entry) return null;
|
|
182
|
+
if (entry.data.enabled === false) return null;
|
|
183
|
+
if (entry.data.expiresAt && Date.parse(entry.data.expiresAt) < Date.now()) return null;
|
|
184
|
+
|
|
185
|
+
const lastUsed = entry.data.lastUsedAt ? Date.parse(entry.data.lastUsedAt) : 0;
|
|
186
|
+
if (Date.now() - lastUsed > LAST_USED_THROTTLE_MS) {
|
|
187
|
+
// updateEntry replaces data wholesale — pass the full object.
|
|
188
|
+
const data = {...entry.data, lastUsedAt: new Date().toISOString()};
|
|
189
|
+
entry.data = data; // keep the cached copy current without invalidating
|
|
190
|
+
// Fire-and-forget, but chained so revoke/update can await it.
|
|
191
|
+
writeChain = writeChain
|
|
192
|
+
.then(() => updateEntry(API_TOKENS_COLLECTION_SLUG, entry.id, data))
|
|
193
|
+
.catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return sanitise(entry);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* List tokens the given admin user may see (project access scope applies).
|
|
201
|
+
*
|
|
202
|
+
* @param {object} user
|
|
203
|
+
* @returns {Promise<object[]>} Sanitised entries
|
|
204
|
+
*/
|
|
205
|
+
export async function listTokensSanitised(user) {
|
|
206
|
+
const {entries} = await listEntries(API_TOKENS_COLLECTION_SLUG, {limit: 0});
|
|
207
|
+
return entries
|
|
208
|
+
.filter(e => canSeeArtefact(user, {meta: {project: e.data?.project}}))
|
|
209
|
+
.map(sanitise);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Fetch a single token, sanitised. Caller is responsible for canSeeArtefact.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} id
|
|
216
|
+
* @returns {Promise<object|null>}
|
|
217
|
+
*/
|
|
218
|
+
export async function getTokenSanitised(id) {
|
|
219
|
+
const entry = await getEntry(API_TOKENS_COLLECTION_SLUG, id);
|
|
220
|
+
return entry ? sanitise(entry) : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Update mutable token fields. The project binding, hash, and hint are fixed
|
|
225
|
+
* for the token's lifetime — revoke and re-issue to rebind.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} id
|
|
228
|
+
* @param {{name?: string, enabled?: boolean, scopes?: object[], expiresAt?: string|null}} patch
|
|
229
|
+
* @returns {Promise<object|null>} Sanitised entry, or null when not found
|
|
230
|
+
* @throws {Error} On validation failure
|
|
231
|
+
*/
|
|
232
|
+
export async function updateToken(id, patch = {}) {
|
|
233
|
+
await writeChain; // drain any in-flight lastUsedAt write first
|
|
234
|
+
const entry = await getEntry(API_TOKENS_COLLECTION_SLUG, id);
|
|
235
|
+
if (!entry) return null;
|
|
236
|
+
|
|
237
|
+
const data = {...entry.data};
|
|
238
|
+
if (patch.name != null) {
|
|
239
|
+
if (typeof patch.name !== 'string' || !patch.name.trim()) throw new Error('Token name is required');
|
|
240
|
+
data.name = patch.name.trim();
|
|
241
|
+
}
|
|
242
|
+
if (patch.enabled != null) data.enabled = patch.enabled !== false;
|
|
243
|
+
if (patch.scopes != null) {
|
|
244
|
+
const scopeError = validateScopes(patch.scopes);
|
|
245
|
+
if (scopeError) throw new Error(scopeError);
|
|
246
|
+
data.scopes = patch.scopes;
|
|
247
|
+
}
|
|
248
|
+
if (patch.expiresAt !== undefined) {
|
|
249
|
+
if (patch.expiresAt != null && Number.isNaN(Date.parse(patch.expiresAt))) {
|
|
250
|
+
throw new Error('expiresAt must be a valid date');
|
|
251
|
+
}
|
|
252
|
+
data.expiresAt = patch.expiresAt || null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const updated = await updateEntry(API_TOKENS_COLLECTION_SLUG, id, data);
|
|
256
|
+
invalidateCache();
|
|
257
|
+
return updated ? sanitise(updated) : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Revoke (delete) a token. Takes effect immediately.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} id
|
|
264
|
+
* @returns {Promise<boolean>}
|
|
265
|
+
*/
|
|
266
|
+
export async function revokeToken(id) {
|
|
267
|
+
await writeChain; // drain any in-flight lastUsedAt write — revoke must be final
|
|
268
|
+
const entry = await getEntry(API_TOKENS_COLLECTION_SLUG, id);
|
|
269
|
+
if (!entry) return false;
|
|
270
|
+
await deleteEntry(API_TOKENS_COLLECTION_SLUG, id);
|
|
271
|
+
invalidateCache();
|
|
272
|
+
return true;
|
|
273
|
+
}
|