domma-cms 0.24.0 → 0.25.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/CLAUDE.md +6 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/crud-tutorial.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/views/api-endpoint-editor.js +1 -0
- package/admin/js/views/api-endpoints.js +7 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/project-detail.js +1 -1
- package/config/menus/admin-sidebar.json +7 -1
- package/package.json +1 -1
- package/server/routes/api/api-endpoints.js +120 -0
- package/server/routes/api/collections.js +2 -2
- package/server/routes/api/endpoints-public.js +88 -0
- package/server/server.js +8 -0
- package/server/services/apiEndpoints.js +409 -0
- package/server/services/apiTokens.js +15 -1
- package/server/services/permissionRegistry.js +13 -0
- package/server/services/presetCollections.js +29 -0
- package/server/services/projects.js +18 -2
- package/server/services/roles.js +23 -13
- package/server/services/scaffolder.js +24 -1
- package/server/services/sidebar-migration.js +1 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Endpoints API (admin management of custom endpoint definitions)
|
|
3
|
+
*
|
|
4
|
+
* GET /api/api-endpoints — list definitions visible to the caller
|
|
5
|
+
* GET /api/api-endpoints/:id — single definition (the builder editor)
|
|
6
|
+
* POST /api/api-endpoints — create (400 validation, 409 duplicate path shape)
|
|
7
|
+
* PUT /api/api-endpoints/:id — update (project binding immutable)
|
|
8
|
+
* DELETE /api/api-endpoints/:id — delete
|
|
9
|
+
*
|
|
10
|
+
* Endpoints are project-scoped artefacts: non-super-admin users with a
|
|
11
|
+
* `projects: []` access scope only see/manage definitions in their projects.
|
|
12
|
+
* Auth middlewares are accepted as DI options so tests can supply no-ops.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
authenticate as defaultAuthenticate,
|
|
16
|
+
requirePermission as defaultRequirePermission
|
|
17
|
+
} from '../../middleware/auth.js';
|
|
18
|
+
import {
|
|
19
|
+
createEndpoint,
|
|
20
|
+
deleteEndpoint,
|
|
21
|
+
getEndpointSanitised,
|
|
22
|
+
listEndpointsSanitised,
|
|
23
|
+
updateEndpoint
|
|
24
|
+
} from '../../services/apiEndpoints.js';
|
|
25
|
+
import {canSeeArtefact} from '../../services/projects.js';
|
|
26
|
+
import {getCollection} from '../../services/collections.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Scoped users must not expose data they cannot see: the collection an
|
|
30
|
+
* endpoint queries is itself an artefact subject to project access scope.
|
|
31
|
+
* Returns true when the user may use the collection (or it doesn't exist —
|
|
32
|
+
* the service's validation produces the clearer 400 for that case).
|
|
33
|
+
*
|
|
34
|
+
* @param {object} user
|
|
35
|
+
* @param {string} slug
|
|
36
|
+
* @returns {Promise<boolean>}
|
|
37
|
+
*/
|
|
38
|
+
async function canUseCollection(user, slug) {
|
|
39
|
+
if (!slug) return true;
|
|
40
|
+
const schema = await getCollection(slug).catch(() => null);
|
|
41
|
+
if (!schema) return true;
|
|
42
|
+
return canSeeArtefact(user, schema);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Register the api-endpoints routes.
|
|
47
|
+
*
|
|
48
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
49
|
+
* @param {{authenticate?: Function, requirePermission?: Function}} [opts]
|
|
50
|
+
* @returns {Promise<void>}
|
|
51
|
+
*/
|
|
52
|
+
export async function apiEndpointsRoutes(fastify, opts = {}) {
|
|
53
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
54
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
55
|
+
|
|
56
|
+
const canRead = {preHandler: [authenticate, requirePermission('api-endpoints', 'read')]};
|
|
57
|
+
const canCreate = {preHandler: [authenticate, requirePermission('api-endpoints', 'create')]};
|
|
58
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('api-endpoints', 'update')]};
|
|
59
|
+
const canDelete = {preHandler: [authenticate, requirePermission('api-endpoints', 'delete')]};
|
|
60
|
+
|
|
61
|
+
const statusFor = (err) => err.code === 'DUPLICATE' ? 409 : 400;
|
|
62
|
+
|
|
63
|
+
fastify.get('/api-endpoints', canRead, async (request) => {
|
|
64
|
+
return listEndpointsSanitised(request.user);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
fastify.get('/api-endpoints/:id', canRead, async (request, reply) => {
|
|
68
|
+
const def = await getEndpointSanitised(request.params.id);
|
|
69
|
+
if (!def) return reply.status(404).send({error: 'Endpoint not found'});
|
|
70
|
+
if (!canSeeArtefact(request.user, {meta: {project: def.project}})) {
|
|
71
|
+
return reply.status(403).send({error: 'Access denied for this project'});
|
|
72
|
+
}
|
|
73
|
+
return def;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
fastify.post('/api-endpoints', canCreate, async (request, reply) => {
|
|
77
|
+
const input = request.body || {};
|
|
78
|
+
if (input.project && !canSeeArtefact(request.user, {meta: {project: input.project}})) {
|
|
79
|
+
return reply.status(403).send({error: 'Access denied for this project'});
|
|
80
|
+
}
|
|
81
|
+
if (!await canUseCollection(request.user, input.collection)) {
|
|
82
|
+
return reply.status(403).send({error: 'Access denied for this collection'});
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const creator = request.user?.name || request.user?.email || null;
|
|
86
|
+
const def = await createEndpoint({...input, createdBy: creator});
|
|
87
|
+
return reply.status(201).send(def);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return reply.status(statusFor(err)).send({error: err.message});
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
fastify.put('/api-endpoints/:id', canUpdate, async (request, reply) => {
|
|
94
|
+
const existing = await getEndpointSanitised(request.params.id);
|
|
95
|
+
if (!existing) return reply.status(404).send({error: 'Endpoint not found'});
|
|
96
|
+
if (!canSeeArtefact(request.user, {meta: {project: existing.project}})) {
|
|
97
|
+
return reply.status(403).send({error: 'Access denied for this project'});
|
|
98
|
+
}
|
|
99
|
+
// The project binding is the endpoint's URL namespace — immutable.
|
|
100
|
+
const {project: _ignored, ...patch} = request.body || {};
|
|
101
|
+
if (!await canUseCollection(request.user, patch.collection ?? existing.collection)) {
|
|
102
|
+
return reply.status(403).send({error: 'Access denied for this collection'});
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
return await updateEndpoint(request.params.id, patch);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return reply.status(statusFor(err)).send({error: err.message});
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
fastify.delete('/api-endpoints/:id', canDelete, async (request, reply) => {
|
|
112
|
+
const existing = await getEndpointSanitised(request.params.id);
|
|
113
|
+
if (!existing) return reply.status(404).send({error: 'Endpoint not found'});
|
|
114
|
+
if (!canSeeArtefact(request.user, {meta: {project: existing.project}})) {
|
|
115
|
+
return reply.status(403).send({error: 'Access denied for this project'});
|
|
116
|
+
}
|
|
117
|
+
await deleteEndpoint(request.params.id);
|
|
118
|
+
return {success: true};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -124,7 +124,7 @@ function redactTokenHash(entry) {
|
|
|
124
124
|
* @param {object} payload - listEntries() result or a single entry
|
|
125
125
|
* @returns {object}
|
|
126
126
|
*/
|
|
127
|
-
function applyReadFieldAllowlist(schema, payload) {
|
|
127
|
+
export function applyReadFieldAllowlist(schema, payload) {
|
|
128
128
|
const fields = schema.api?.read?.fields;
|
|
129
129
|
if (!Array.isArray(fields) || fields.length === 0) return payload;
|
|
130
130
|
const strip = (e) => ({
|
|
@@ -145,7 +145,7 @@ function applyReadFieldAllowlist(schema, payload) {
|
|
|
145
145
|
* @param {object} reply - Fastify reply
|
|
146
146
|
* @returns {Promise<object|undefined>}
|
|
147
147
|
*/
|
|
148
|
-
async function checkPublicAccess(schema, operation, request, reply) {
|
|
148
|
+
export async function checkPublicAccess(schema, operation, request, reply) {
|
|
149
149
|
const access = schema.api?.[operation];
|
|
150
150
|
if (!access?.enabled) {
|
|
151
151
|
return reply.status(403).send({ error: `Public ${operation} is disabled for this collection` });
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom API Endpoints — public surface
|
|
3
|
+
*
|
|
4
|
+
* One catch-all serves every user-defined endpoint:
|
|
5
|
+
* GET /api/x/<project><path> e.g. /api/x/world-cup/fixtures-day/2026-06-11
|
|
6
|
+
*
|
|
7
|
+
* Definitions live in the `api-endpoints` preset collection and are matched
|
|
8
|
+
* at request time by services/apiEndpoints.js. Auth (public / token / role),
|
|
9
|
+
* project binding, token scopes, and the read field allowlist all reuse the
|
|
10
|
+
* public collections machinery via a synthesized schema — one battle-tested
|
|
11
|
+
* code path for every external read.
|
|
12
|
+
*
|
|
13
|
+
* Unknown project, unknown path, and disabled endpoints all 404 identically
|
|
14
|
+
* (no existence oracle). GET-only in v1.
|
|
15
|
+
*/
|
|
16
|
+
import {EndpointParamError, executeEndpoint, matchEndpoint} from '../../services/apiEndpoints.js';
|
|
17
|
+
import {applyReadFieldAllowlist, checkPublicAccess} from './collections.js';
|
|
18
|
+
import * as cache from '../../services/cache/index.js';
|
|
19
|
+
|
|
20
|
+
/** Cached sentinel for single-mode misses — a miss is a cacheable answer. */
|
|
21
|
+
const NOT_FOUND = {__endpointNotFound: true};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Stable query-string representation for cache keys.
|
|
25
|
+
*
|
|
26
|
+
* @param {Record<string, string>} query
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function sortedQueryString(query) {
|
|
30
|
+
return Object.keys(query || {})
|
|
31
|
+
.sort()
|
|
32
|
+
.map(k => `${k}=${query[k]}`)
|
|
33
|
+
.join('&');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function endpointsPublicRoutes(fastify) {
|
|
37
|
+
fastify.get('/x/*', async (request, reply) => {
|
|
38
|
+
const segments = String(request.params['*'] || '').split('/').filter(Boolean);
|
|
39
|
+
if (segments.length < 2) return reply.status(404).send({ error: 'Not found' });
|
|
40
|
+
|
|
41
|
+
const [project, ...rest] = segments;
|
|
42
|
+
const match = await matchEndpoint(project, rest);
|
|
43
|
+
if (!match) return reply.status(404).send({ error: 'Not found' });
|
|
44
|
+
|
|
45
|
+
const {def, params} = match;
|
|
46
|
+
|
|
47
|
+
// Synthesized schema — checkPublicAccess reads exactly api.read,
|
|
48
|
+
// slug (token scopes), and meta.project (token project binding).
|
|
49
|
+
const synth = {
|
|
50
|
+
slug: def.collection,
|
|
51
|
+
api: { read: { enabled: true, access: def.auth, fields: def.fields } },
|
|
52
|
+
meta: { project: def.project }
|
|
53
|
+
};
|
|
54
|
+
const denied = await checkPublicAccess(synth, 'read', request, reply);
|
|
55
|
+
if (denied !== undefined) return;
|
|
56
|
+
|
|
57
|
+
const run = async () => {
|
|
58
|
+
const result = await executeEndpoint(def, params, request.query);
|
|
59
|
+
if (def.mode === 'single') {
|
|
60
|
+
return result === null ? NOT_FOUND : applyReadFieldAllowlist(synth, result);
|
|
61
|
+
}
|
|
62
|
+
return applyReadFieldAllowlist(synth, result);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
// Only public responses are cacheable — token/role responses are
|
|
67
|
+
// caller-dependent. Both tags are invalidated by the service layer
|
|
68
|
+
// on every write path: entry mutations bust collection:<slug>, and
|
|
69
|
+
// definition edits (entries in api-endpoints) bust
|
|
70
|
+
// collection:api-endpoints. No manual invalidation needed.
|
|
71
|
+
const payload = def.auth === 'public'
|
|
72
|
+
? await cache.wrap(
|
|
73
|
+
`apix:${project}:${rest.join('/')}:${sortedQueryString(request.query)}`,
|
|
74
|
+
run,
|
|
75
|
+
{ tags: [`collection:${def.collection}`, 'collection:api-endpoints'] }
|
|
76
|
+
)
|
|
77
|
+
: await run();
|
|
78
|
+
|
|
79
|
+
if (payload?.__endpointNotFound) return reply.status(404).send({ error: 'Not found' });
|
|
80
|
+
return payload;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err instanceof EndpointParamError) {
|
|
83
|
+
return reply.status(400).send({ error: err.message });
|
|
84
|
+
}
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
package/server/server.js
CHANGED
|
@@ -209,6 +209,10 @@ try {
|
|
|
209
209
|
groupText: 'System',
|
|
210
210
|
item: {text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens'}
|
|
211
211
|
});
|
|
212
|
+
await ensureSidebarItem({
|
|
213
|
+
groupText: 'Data',
|
|
214
|
+
item: {text: 'API Builder', url: '#/api-endpoints', icon: 'code', permission: 'api-endpoints'}
|
|
215
|
+
});
|
|
212
216
|
} catch (err) {
|
|
213
217
|
app.log.warn(`[admin-sidebar] Migration skipped: ${err.message}`);
|
|
214
218
|
}
|
|
@@ -285,6 +289,8 @@ const {menusRoutes} = await import('./routes/api/menus.js');
|
|
|
285
289
|
const {menuLocationsRoutes} = await import('./routes/api/menu-locations.js');
|
|
286
290
|
const {projectsRoutes} = await import('./routes/api/projects.js');
|
|
287
291
|
const {apiTokensRoutes} = await import('./routes/api/api-tokens.js');
|
|
292
|
+
const {apiEndpointsRoutes} = await import('./routes/api/api-endpoints.js');
|
|
293
|
+
const {endpointsPublicRoutes} = await import('./routes/api/endpoints-public.js');
|
|
288
294
|
const {sidebarRoutes} = await import('./routes/api/sidebar.js');
|
|
289
295
|
const { mediaRoutes } = await import('./routes/api/media.js');
|
|
290
296
|
const { usersRoutes } = await import('./routes/api/users.js');
|
|
@@ -311,6 +317,8 @@ await app.register(menusRoutes, {prefix: '/api'});
|
|
|
311
317
|
await app.register(menuLocationsRoutes, {prefix: '/api'});
|
|
312
318
|
await app.register(projectsRoutes, {prefix: '/api'});
|
|
313
319
|
await app.register(apiTokensRoutes, {prefix: '/api'});
|
|
320
|
+
await app.register(apiEndpointsRoutes, {prefix: '/api'});
|
|
321
|
+
await app.register(endpointsPublicRoutes, {prefix: '/api'});
|
|
314
322
|
await app.register(sidebarRoutes, {prefix: '/api'});
|
|
315
323
|
await app.register(mediaRoutes, { prefix: '/api' });
|
|
316
324
|
await app.register(usersRoutes, { prefix: '/api' });
|
|
@@ -0,0 +1,409 @@
|
|
|
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 {CORE_PROJECT_SLUG, canSeeArtefact, getProject, resolveArtefactProject} 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
|
+
// An endpoint may only expose collections from its own project or core —
|
|
117
|
+
// the URL namespace must match where the data lives, and scoped users
|
|
118
|
+
// must not be able to publish another project's data through their own.
|
|
119
|
+
const collectionProject = resolveArtefactProject(schema);
|
|
120
|
+
if (collectionProject !== data.project && collectionProject !== CORE_PROJECT_SLUG) {
|
|
121
|
+
throw new Error(`Collection "${data.collection}" belongs to project "${collectionProject}" — an endpoint may only expose collections from its own project or core`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (typeof data.path !== 'string' || !data.path.startsWith('/')) {
|
|
125
|
+
throw new Error('Path must start with "/"');
|
|
126
|
+
}
|
|
127
|
+
const segments = data.path.split('/').filter(Boolean);
|
|
128
|
+
if (!segments.length) throw new Error('Path needs at least one segment');
|
|
129
|
+
const paramNames = new Set();
|
|
130
|
+
for (const seg of segments) {
|
|
131
|
+
if (PARAM_SEGMENT_RE.test(seg)) {
|
|
132
|
+
paramNames.add(seg.slice(1));
|
|
133
|
+
} else if (!STATIC_SEGMENT_RE.test(seg)) {
|
|
134
|
+
throw new Error(`Invalid path segment "${seg}" — use lowercase letters, numbers, hyphens, or :param`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const auth = data.auth || 'public';
|
|
139
|
+
if (auth !== 'public' && auth !== 'token' && !getRoleMap().has(auth)) {
|
|
140
|
+
throw new Error(`Unknown auth mode "${auth}" — use public, token, or a role name`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (data.mode != null && !MODES.includes(data.mode)) throw new Error('mode must be "list" or "single"');
|
|
144
|
+
if (data.order != null && !ORDERS.includes(data.order)) throw new Error('order must be "asc" or "desc"');
|
|
145
|
+
if (data.limit != null && (!Number.isInteger(data.limit) || data.limit < 0 || data.limit > 500)) {
|
|
146
|
+
throw new Error('limit must be an integer between 0 and 500');
|
|
147
|
+
}
|
|
148
|
+
if (data.fields != null && (!Array.isArray(data.fields) || data.fields.some(f => typeof f !== 'string'))) {
|
|
149
|
+
throw new Error('fields must be an array of field names');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (data.filter != null) {
|
|
153
|
+
if (typeof data.filter !== 'object' || Array.isArray(data.filter)) {
|
|
154
|
+
throw new Error('filter must be an object of { field_op: value }');
|
|
155
|
+
}
|
|
156
|
+
for (const [key, value] of Object.entries(data.filter)) {
|
|
157
|
+
const {field} = parseFilterKey(key);
|
|
158
|
+
if (!field) throw new Error(`Invalid filter key "${key}"`);
|
|
159
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
|
160
|
+
throw new Error(`Filter "${key}" must have a string, number, or boolean value`);
|
|
161
|
+
}
|
|
162
|
+
// Save-time check: every {{params.X}} must have a matching :X segment.
|
|
163
|
+
if (typeof value === 'string') {
|
|
164
|
+
for (const m of value.matchAll(TEMPLATE_RE)) {
|
|
165
|
+
if (m[1] === 'params' && !paramNames.has(m[2])) {
|
|
166
|
+
throw new Error(`Filter "${key}" references {{params.${m[2]}}} but the path has no :${m[2]} segment`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Duplicate shape per project — `/a/:x` and `/a/:y` collide.
|
|
174
|
+
const shape = pathShape(data.path);
|
|
175
|
+
const {entries} = await listEntries(API_ENDPOINTS_COLLECTION_SLUG, {limit: 0});
|
|
176
|
+
const clash = entries.find(e =>
|
|
177
|
+
e.id !== excludeId
|
|
178
|
+
&& e.data?.project === data.project
|
|
179
|
+
&& pathShape(e.data?.path || '') === shape
|
|
180
|
+
);
|
|
181
|
+
if (clash) {
|
|
182
|
+
const err = new Error(`An endpoint with the path shape "${data.path}" already exists in project "${data.project}" ("${clash.data.name}")`);
|
|
183
|
+
err.code = 'DUPLICATE';
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Create an endpoint definition.
|
|
190
|
+
*
|
|
191
|
+
* @param {object} input
|
|
192
|
+
* @returns {Promise<object>} Sanitised definition
|
|
193
|
+
* @throws {Error} Validation failure (code 'DUPLICATE' for path clashes)
|
|
194
|
+
*/
|
|
195
|
+
export async function createEndpoint(input) {
|
|
196
|
+
const data = {
|
|
197
|
+
name: String(input.name || '').trim(),
|
|
198
|
+
project: input.project,
|
|
199
|
+
path: input.path,
|
|
200
|
+
collection: input.collection,
|
|
201
|
+
auth: input.auth || 'public',
|
|
202
|
+
mode: input.mode || 'list',
|
|
203
|
+
filter: input.filter || {},
|
|
204
|
+
sort: input.sort || null,
|
|
205
|
+
order: input.order || 'desc',
|
|
206
|
+
limit: input.limit ?? 50,
|
|
207
|
+
fields: input.fields || [],
|
|
208
|
+
enabled: input.enabled !== false,
|
|
209
|
+
createdBy: input.createdBy || null
|
|
210
|
+
};
|
|
211
|
+
await validateDefinition(data);
|
|
212
|
+
const entry = await createEntry(API_ENDPOINTS_COLLECTION_SLUG, data, {createdBy: data.createdBy, source: 'admin'});
|
|
213
|
+
invalidateRegistry();
|
|
214
|
+
return sanitise(entry);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Update an endpoint. The project binding is immutable (it is the endpoint's
|
|
219
|
+
* URL namespace) — create a new endpoint to move one.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} id
|
|
222
|
+
* @param {object} patch
|
|
223
|
+
* @returns {Promise<object|null>}
|
|
224
|
+
*/
|
|
225
|
+
export async function updateEndpoint(id, patch = {}) {
|
|
226
|
+
const entry = await getEntry(API_ENDPOINTS_COLLECTION_SLUG, id);
|
|
227
|
+
if (!entry) return null;
|
|
228
|
+
|
|
229
|
+
const data = {...entry.data};
|
|
230
|
+
for (const key of ['name', 'path', 'collection', 'auth', 'mode', 'filter', 'sort', 'order', 'limit', 'fields']) {
|
|
231
|
+
if (patch[key] !== undefined) data[key] = patch[key];
|
|
232
|
+
}
|
|
233
|
+
if (patch.enabled !== undefined) data.enabled = patch.enabled !== false;
|
|
234
|
+
if (typeof data.name === 'string') data.name = data.name.trim();
|
|
235
|
+
|
|
236
|
+
await validateDefinition(data, {excludeId: id});
|
|
237
|
+
const updated = await updateEntry(API_ENDPOINTS_COLLECTION_SLUG, id, data);
|
|
238
|
+
invalidateRegistry();
|
|
239
|
+
return updated ? sanitise(updated) : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Delete an endpoint definition.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} id
|
|
246
|
+
* @returns {Promise<boolean>}
|
|
247
|
+
*/
|
|
248
|
+
export async function deleteEndpoint(id) {
|
|
249
|
+
const entry = await getEntry(API_ENDPOINTS_COLLECTION_SLUG, id);
|
|
250
|
+
if (!entry) return false;
|
|
251
|
+
await deleteEntry(API_ENDPOINTS_COLLECTION_SLUG, id);
|
|
252
|
+
invalidateRegistry();
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* List endpoints visible to the given admin user (project scope applies).
|
|
258
|
+
*
|
|
259
|
+
* @param {object} user
|
|
260
|
+
* @returns {Promise<object[]>}
|
|
261
|
+
*/
|
|
262
|
+
export async function listEndpointsSanitised(user) {
|
|
263
|
+
const {entries} = await listEntries(API_ENDPOINTS_COLLECTION_SLUG, {limit: 0});
|
|
264
|
+
return entries
|
|
265
|
+
.filter(e => canSeeArtefact(user, {meta: {project: e.data?.project}}))
|
|
266
|
+
.map(sanitise);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Fetch a single endpoint, sanitised. Caller does the canSeeArtefact check.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} id
|
|
273
|
+
* @returns {Promise<object|null>}
|
|
274
|
+
*/
|
|
275
|
+
export async function getEndpointSanitised(id) {
|
|
276
|
+
const entry = await getEntry(API_ENDPOINTS_COLLECTION_SLUG, id);
|
|
277
|
+
return entry ? sanitise(entry) : null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Look up an endpoint by project + path SHAPE (`:x` ≡ `:y`).
|
|
282
|
+
* Used for scaffolder idempotency.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} project
|
|
285
|
+
* @param {string} path
|
|
286
|
+
* @returns {Promise<object|null>}
|
|
287
|
+
*/
|
|
288
|
+
export async function findEndpointByPath(project, path) {
|
|
289
|
+
const shape = pathShape(path);
|
|
290
|
+
const {entries} = await listEntries(API_ENDPOINTS_COLLECTION_SLUG, {limit: 0});
|
|
291
|
+
const entry = entries.find(e => e.data?.project === project && pathShape(e.data?.path || '') === shape);
|
|
292
|
+
return entry ? sanitise(entry) : null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Build the compiled registry from enabled definitions. Within a project,
|
|
297
|
+
* endpoints sort by static-segment count descending so static paths beat
|
|
298
|
+
* `:param` paths when both match.
|
|
299
|
+
*
|
|
300
|
+
* @returns {Promise<Map<string, object[]>>}
|
|
301
|
+
*/
|
|
302
|
+
async function buildRegistry() {
|
|
303
|
+
const {entries} = await listEntries(API_ENDPOINTS_COLLECTION_SLUG, {limit: 0});
|
|
304
|
+
const map = new Map();
|
|
305
|
+
for (const entry of entries) {
|
|
306
|
+
const def = sanitise(entry);
|
|
307
|
+
if (!def.enabled || !def.project || !def.path) continue;
|
|
308
|
+
const segments = def.path.split('/').filter(Boolean).map(s =>
|
|
309
|
+
s.startsWith(':') ? {param: s.slice(1)} : {static: s}
|
|
310
|
+
);
|
|
311
|
+
const compiled = {
|
|
312
|
+
def,
|
|
313
|
+
segments,
|
|
314
|
+
staticCount: segments.filter(s => s.static).length
|
|
315
|
+
};
|
|
316
|
+
if (!map.has(def.project)) map.set(def.project, []);
|
|
317
|
+
map.get(def.project).push(compiled);
|
|
318
|
+
}
|
|
319
|
+
for (const list of map.values()) {
|
|
320
|
+
list.sort((a, b) => b.staticCount - a.staticCount);
|
|
321
|
+
}
|
|
322
|
+
return map;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Match a request path against the registry.
|
|
327
|
+
*
|
|
328
|
+
* @param {string} project - First wildcard segment
|
|
329
|
+
* @param {string[]} segments - Remaining wildcard segments (raw, URL-encoded)
|
|
330
|
+
* @returns {Promise<{def: object, params: Record<string, string>}|null>}
|
|
331
|
+
*/
|
|
332
|
+
export async function matchEndpoint(project, segments) {
|
|
333
|
+
if (!registry) registry = await buildRegistry();
|
|
334
|
+
const candidates = registry.get(project);
|
|
335
|
+
if (!candidates) return null;
|
|
336
|
+
|
|
337
|
+
outer:
|
|
338
|
+
for (const c of candidates) {
|
|
339
|
+
if (c.segments.length !== segments.length) continue;
|
|
340
|
+
const params = {};
|
|
341
|
+
for (let i = 0; i < c.segments.length; i++) {
|
|
342
|
+
const spec = c.segments[i];
|
|
343
|
+
if (spec.static !== undefined) {
|
|
344
|
+
if (spec.static !== segments[i]) continue outer;
|
|
345
|
+
} else {
|
|
346
|
+
try { params[spec.param] = decodeURIComponent(segments[i]); }
|
|
347
|
+
catch { params[spec.param] = segments[i]; }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return {def: c.def, params};
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Substitute {{params.X}} / {{query.X}} placeholders in the definition's
|
|
357
|
+
* filter values. Unresolved params throw (route maps to 400); a clause whose
|
|
358
|
+
* {{query.X}} stays unresolved is DROPPED, so query placeholders act as
|
|
359
|
+
* optional refinements.
|
|
360
|
+
*
|
|
361
|
+
* @param {object} filterTemplate
|
|
362
|
+
* @param {Record<string, string>} params
|
|
363
|
+
* @param {Record<string, string>} query
|
|
364
|
+
* @returns {object} Concrete filter for the filterEngine
|
|
365
|
+
*/
|
|
366
|
+
function substituteFilter(filterTemplate, params, query) {
|
|
367
|
+
const out = {};
|
|
368
|
+
for (const [key, raw] of Object.entries(filterTemplate || {})) {
|
|
369
|
+
if (typeof raw !== 'string') { out[key] = raw; continue; }
|
|
370
|
+
|
|
371
|
+
let dropped = false;
|
|
372
|
+
const value = raw.replace(TEMPLATE_RE, (_m, kind, name) => {
|
|
373
|
+
const source = kind === 'params' ? params : query;
|
|
374
|
+
const v = source?.[name];
|
|
375
|
+
if (v === undefined || v === null || v === '') {
|
|
376
|
+
if (kind === 'params') {
|
|
377
|
+
throw new EndpointParamError(`Missing required path parameter "${name}"`);
|
|
378
|
+
}
|
|
379
|
+
dropped = true;
|
|
380
|
+
return '';
|
|
381
|
+
}
|
|
382
|
+
return String(v);
|
|
383
|
+
});
|
|
384
|
+
if (!dropped) out[key] = value;
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Execute a matched endpoint definition.
|
|
391
|
+
*
|
|
392
|
+
* @param {object} def
|
|
393
|
+
* @param {Record<string, string>} params - Decoded path params
|
|
394
|
+
* @param {Record<string, string>} query - Request query object
|
|
395
|
+
* @returns {Promise<object|null>} list payload, single entry, or null (single miss)
|
|
396
|
+
* @throws {EndpointParamError}
|
|
397
|
+
*/
|
|
398
|
+
export async function executeEndpoint(def, params, query) {
|
|
399
|
+
const filter = substituteFilter(def.filter, params, query || {});
|
|
400
|
+
const result = await listEntries(def.collection, {
|
|
401
|
+
page: 1,
|
|
402
|
+
limit: def.mode === 'single' ? 1 : (def.limit ?? 50),
|
|
403
|
+
sort: def.sort || 'createdAt',
|
|
404
|
+
order: def.order || 'desc',
|
|
405
|
+
filter: Object.keys(filter).length ? filter : undefined
|
|
406
|
+
});
|
|
407
|
+
if (def.mode === 'single') return result.entries[0] || null;
|
|
408
|
+
return result;
|
|
409
|
+
}
|