-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathrest-api-plugin.ts
More file actions
299 lines (268 loc) · 15 KB
/
Copy pathrest-api-plugin.ts
File metadata and controls
299 lines (268 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
import { RestServer, RestKernelManager, RestProtocol } from './rest-server.js';
import { RestServerConfig } from '@objectstack/spec/api';
import { registerPackageRoutes } from './package-routes.js';
import { registerExternalDatasourceRoutes } from './external-datasource-routes.js';
import type { PackageService } from '@objectstack/service-package';
import { SysImportJob } from '@objectstack/platform-objects/audit';
export interface RestApiPluginConfig {
serverServiceName?: string;
protocolServiceName?: string;
/**
* Optional override for the kernel-manager service name. When the service
* is registered (by @objectstack/runtime's MultiProjectPlugin), scoped
* routes resolve per-environment protocols at request time.
*/
kernelManagerServiceName?: string;
api?: RestServerConfig;
}
/**
* REST API Plugin
*
* Responsibilities:
* 1. Consumes 'http.server' (or configured service)
* 2. Consumes 'protocol' (the `RestProtocol` slice — DataProtocol + MetadataProtocol, ADR-0076 D9)
* 3. Instantiates RestServer to auto-generate routes
*/
export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
return {
name: 'com.objectstack.rest.api',
version: '1.0.0',
init: async (ctx: PluginContext) => {
// Register the async-import job object so its state/progress/history
// is queryable in Studio and readable by the import-job routes.
// The REST plugin owns the import feature, so it owns this object
// (there is no separate import service). Mirrors JobServicePlugin.
try {
ctx.getService<{ register(m: any): void }>('manifest').register({
id: 'com.objectstack.rest.api',
name: 'REST API',
version: '1.0.0',
type: 'plugin',
scope: 'system',
defaultDatasource: 'cloud',
namespace: 'sys',
objects: [SysImportJob],
});
} catch (err) {
ctx.logger.warn('RestApiPlugin: manifest service unavailable; sys_import_job not registered', err as any);
}
},
start: async (ctx: PluginContext) => {
const serverService = config.serverServiceName || 'http.server';
const protocolService = config.protocolServiceName || 'protocol';
let server: IHttpServer | undefined;
let protocol: RestProtocol | undefined;
try {
server = ctx.getService<IHttpServer>(serverService);
} catch (e) {
// Ignore missing service
}
try {
protocol = ctx.getService<RestProtocol>(protocolService);
} catch (e) {
// Ignore missing service
}
// Optional — only present when MultiProjectPlugin is mounted. When
// available, RestServer will resolve a per-environment protocol at
// request time for scoped (`/environments/:environmentId/...`) routes.
let kernelManager: RestKernelManager | undefined;
const kernelManagerService = config.kernelManagerServiceName || 'kernel-manager';
try {
kernelManager = ctx.getService<RestKernelManager>(kernelManagerService);
} catch (e) {
// Single-kernel deployment — fall back to the control protocol
}
// Optional — only present in runtime mode. When available,
// RestServer will resolve hostname → environmentId on unscoped
// routes so a remote runtime node can dispatch every request
// to the matching per-environment kernel without requiring callers
// to know the environmentId.
let envRegistry: any;
try {
envRegistry = ctx.getService<any>('env-registry');
} catch (e) {
// Not running in runtime/multi-environment mode — fine.
}
// Optional default-project provider — registered by
// `createSingleEnvironmentPlugin` in single-environment local mode.
// Lets RestServer route bare `/api/v1/data/...` URLs into the
// lone project's kernel.
const defaultEnvironmentIdProvider = (): string | undefined => {
try {
const dp: any = ctx.getService('default-project');
return dp?.environmentId;
} catch { return undefined; }
};
// Auth service resolver — used by RestServer.resolveExecCtx in
// single-kernel deployments where there is no kernelManager.
// Multi-kernel paths look up auth via kernelManager.getOrCreate,
// so this provider is the single-kernel fallback.
const authServiceProvider = async (_environmentId?: string): Promise<any | undefined> => {
try {
return ctx.getService<any>('auth');
} catch { return undefined; }
};
// ObjectQL resolver — single-kernel fallback so resolveExecCtx
// can run sys_member / sys_user_permission_set lookups when
// there is no kernelManager wired (e.g. `pnpm dev:crm`).
const objectQLProvider = async (_environmentId?: string): Promise<any | undefined> => {
try {
return ctx.getService<any>('objectql');
} catch { return undefined; }
};
// Email service resolver — used by POST /email/send. Single-
// kernel deployments resolve from the local kernel; multi-
// tenant paths would resolve via kernelManager.getOrCreate.
const emailServiceProvider = async (_environmentId?: string): Promise<any | undefined> => {
try {
return ctx.getService<any>('email');
} catch { return undefined; }
};
// Sharing service resolver — used by /data/:object/:id/shares.
const sharingServiceProvider = async (_environmentId?: string): Promise<any | undefined> => {
try {
return ctx.getService<any>('sharing');
} catch { return undefined; }
};
// Reports service resolver — used by /reports/* routes.
const reportsServiceProvider = async (_environmentId?: string): Promise<any | undefined> => {
try {
return ctx.getService<any>('reports');
} catch { return undefined; }
};
// Approvals service resolver — used by /approvals/* routes.
const approvalsServiceProvider = async (_environmentId?: string): Promise<any | undefined> => {
try {
return ctx.getService<any>('approvals');
} catch { return undefined; }
};
// Sharing-rule service resolver — used by /sharing/rules/* routes.
const sharingRulesServiceProvider = async (_environmentId?: string): Promise<any | undefined> => {
try {
return ctx.getService<any>('sharingRules');
} catch { return undefined; }
};
// i18n service resolver — used to localize view / action / object
// metadata. Single-kernel fallback so labels and select options
// get translated even without a full multi-tenant kernelManager.
const i18nServiceProvider = async (_environmentId?: string): Promise<any | undefined> => {
try {
return ctx.getService<any>('i18n');
} catch { return undefined; }
};
// Analytics service resolver — used by /analytics/dataset/query
// (ADR-0021 dataset preview/query). Returns undefined when no
// analytics service is registered so the route fails cleanly (501).
const analyticsServiceProvider = async (_environmentId?: string): Promise<any | undefined> => {
try {
return ctx.getService<any>('analytics');
} catch { return undefined; }
};
// Settings service resolver — used by resolveExecCtx to resolve the
// reference timezone/locale (localization manifest) through the 4-tier
// cascade incl. the `OS_LOCALIZATION_TIMEZONE` env override. Returns
// undefined when no settings service is registered (UTC default).
const settingsServiceProvider = async (_environmentId?: string): Promise<any | undefined> => {
try {
return ctx.getService<any>('settings');
} catch { return undefined; }
};
// Security service resolver — used by the ADR-0090 D5/D9
// /security/suggested-bindings routes and the D6 /security/explain
// route (plugin-security). Returns undefined when plugin-security
// is not mounted so the routes fail cleanly (501).
const securityServiceProvider = async (_environmentId?: string): Promise<any | undefined> => {
try {
return ctx.getService<any>('security');
} catch { return undefined; }
};
if (!server) {
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
return;
}
if (!protocol) {
ctx.logger.warn(`RestApiPlugin: Protocol service '${protocolService}' not found. REST routes skipped.`);
return;
}
ctx.logger.info('Hydrating REST API from Protocol...');
// Single-env service-existence probe for nav capability gates
// (ADR-0057 D10). Multi-env uses the per-request kernel instead.
const serviceExistsProvider = (name: string): boolean => {
try { return ctx.getService<any>(name) != null; } catch { return false; }
};
try {
const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider);
restServer.registerRoutes();
ctx.logger.info('REST API successfully registered');
// ADR-0056 D2 (warn → enforce, ENFORCED): the global default is
// secure-by-default — anonymous /data/* is denied unless the
// deployment explicitly opts out. The warning remains for that
// explicit opt-out so a fail-open posture is always visible.
if ((config.api as any)?.api?.requireAuth === false) {
ctx.logger.warn(
'[security] anonymous access to the data API is ALLOWED (api.requireAuth=false, explicit opt-out) — ' +
'objects without OWD/RLS are world-readable. Remove the opt-out for secure-by-default and ' +
'expose public records via share-links / publicSharing / public forms (ADR-0056 D2).',
);
}
// Misplaced-key guard: the effective key is `api.api.requireAuth`
// (RestApiPluginConfig.api is the full RestServerConfig). A flat
// `api.requireAuth` is silently ignored by normalizeConfig — under
// the deny default that turns an INTENDED public deployment into a
// 401 outage with no diagnostic, so name the mistake loudly.
if ((config.api as any)?.requireAuth !== undefined) {
ctx.logger.warn(
'[security] `api.requireAuth` is set at the WRONG nesting level and has NO effect — ' +
'move it to `api.api.requireAuth` (RestServerConfig.api.requireAuth). ' +
`The effective value this boot is ${(config.api as any)?.api?.requireAuth ?? true}.`,
);
}
} catch (err: any) {
ctx.logger.error('Failed to register REST API routes', { error: err.message } as any);
throw err;
}
const basePath = config.api?.api?.basePath || '/api';
const version = config.api?.api?.version || 'v1';
const versionedBase = `${basePath}/${version}`;
const enableProjectScoping = config.api?.api?.enableProjectScoping ?? false;
const projectResolution = config.api?.api?.projectResolution ?? 'auto';
// Register package management routes if the service is available.
try {
const packageService = ctx.getService<PackageService>('package');
if (packageService) {
if (enableProjectScoping && projectResolution === 'required') {
// Only register the scoped variant
registerPackageRoutes(server, packageService, `${versionedBase}/environments/:environmentId`, {
protocol,
});
} else {
registerPackageRoutes(server, packageService, versionedBase, { protocol });
if (enableProjectScoping) {
registerPackageRoutes(server, packageService, `${versionedBase}/environments/:environmentId`, {
protocol,
});
}
}
ctx.logger.info('Package management routes registered');
}
} catch (e) {
// Package service not available, skip
ctx.logger.debug('Package service not available, package routes skipped');
}
// External Datasource Federation routes (ADR-0015): catalog / draft /
// import / validate. Registered unconditionally — they degrade
// gracefully (503) when the `external-datasource` service is absent.
// NOTE: the datasource *lifecycle* routes (ADR-0015 Addendum:
// list / test / create / update / remove) moved to the private
// `@objectstack/datasource-admin` package, which registers its own.
try {
registerExternalDatasourceRoutes(server, ctx, versionedBase);
ctx.logger.info('Datasource federation routes registered');
} catch (e: any) {
ctx.logger.warn('Datasource federation routes registration failed', { error: e?.message });
}
}
};
}