Skip to content

Commit ab706b1

Browse files
committed
feat: integrate service-tenant plugin and update related configurations
1 parent 30aa288 commit ab706b1

File tree

8 files changed

+310
-1
lines changed

8 files changed

+310
-1
lines changed

apps/server/objectstack.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { AIServicePlugin } from '@objectstack/service-ai';
2323
import { AutomationServicePlugin } from '@objectstack/service-automation';
2424
import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
2525
import { PackageServicePlugin } from '@objectstack/service-package';
26+
import { createTenantPlugin } from '@objectstack/service-tenant';
2627
import CrmApp from '../../examples/app-crm/objectstack.config';
2728
import TodoApp from '../../examples/app-todo/objectstack.config';
2829
import BiPluginManifest from '../../examples/plugin-bi/objectstack.config';
@@ -75,6 +76,7 @@ export default defineStack({
7576
new DriverPlugin(new InMemoryDriver(), 'memory'),
7677
new DriverPlugin(tursoDriver, 'turso'),
7778
new PackageServicePlugin(), // Package management service
79+
createTenantPlugin({ registerSystemObjects: true, registerLegacyTenantDatabase: false }),
7880
new AppPlugin(CrmApp),
7981
new AppPlugin(TodoApp),
8082
new AppPlugin(BiPluginManifest),

apps/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@objectstack/service-automation": "workspace:*",
3737
"@objectstack/service-feed": "workspace:*",
3838
"@objectstack/service-package": "workspace:*",
39+
"@objectstack/service-tenant": "workspace:*",
3940
"@objectstack/spec": "workspace:*",
4041
"hono": "^4.12.12",
4142
"pino": "^10.3.1",

apps/studio/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@objectstack/service-analytics": "workspace:*",
4343
"@objectstack/service-automation": "workspace:*",
4444
"@objectstack/service-feed": "workspace:*",
45+
"@objectstack/service-tenant": "workspace:*",
4546
"@objectstack/spec": "workspace:*",
4647
"@tanstack/react-router": "^1.91.6",
4748
"@radix-ui/react-avatar": "^1.1.11",

apps/studio/server/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { SecurityPlugin } from '@objectstack/plugin-security';
3131
import { AuditPlugin } from '@objectstack/plugin-audit';
3232
import { SetupPlugin } from '@objectstack/plugin-setup';
3333
import { FeedServicePlugin } from '@objectstack/service-feed';
34+
import { createTenantPlugin } from '@objectstack/service-tenant';
3435
import { MetadataPlugin } from '@objectstack/metadata';
3536
import { AIServicePlugin } from '@objectstack/service-ai';
3637
import { AutomationServicePlugin } from '@objectstack/service-automation';
@@ -128,6 +129,7 @@ async function ensureKernel(): Promise<ObjectKernel> {
128129
await kernel.use(new SecurityPlugin());
129130
await kernel.use(new AuditPlugin());
130131
await kernel.use(new FeedServicePlugin());
132+
await kernel.use(createTenantPlugin({ registerSystemObjects: true, registerLegacyTenantDatabase: false }));
131133
await kernel.use(new MetadataPlugin({ watch: false }));
132134
await kernel.use(new AIServicePlugin());
133135
await kernel.use(new AutomationServicePlugin());

apps/studio/src/routes/environments.$environmentId.index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,11 @@ function EnvironmentOverviewComponent() {
6060
if (!ok) return;
6161
setRotating(true);
6262
try {
63-
await client?.environments?.rotateCredential?.(env.id);
63+
const newToken =
64+
typeof crypto !== 'undefined' && 'randomUUID' in crypto
65+
? crypto.randomUUID()
66+
: `tok_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
67+
await client?.environments?.rotateCredential?.(env.id, newToken);
6468
toast({
6569
title: 'Credential rotation started',
6670
description: 'The new credential will propagate to all runtimes.',

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,70 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
317317
}
318318
});
319319

320+
// ── Cloud (Environments) ─────────────────────────────────────
321+
server.get(`${prefix}/cloud/environments`, async (req: any, res: any) => {
322+
try {
323+
const result = await dispatcher.handleCloud('/environments', 'GET', {}, req.query, { request: req });
324+
sendResult(result, res);
325+
} catch (err: any) {
326+
errorResponse(err, res);
327+
}
328+
});
329+
330+
server.post(`${prefix}/cloud/environments`, async (req: any, res: any) => {
331+
try {
332+
const result = await dispatcher.handleCloud('/environments', 'POST', req.body, {}, { request: req });
333+
sendResult(result, res);
334+
} catch (err: any) {
335+
errorResponse(err, res);
336+
}
337+
});
338+
339+
server.get(`${prefix}/cloud/environments/:id`, async (req: any, res: any) => {
340+
try {
341+
const result = await dispatcher.handleCloud(`/environments/${req.params.id}`, 'GET', {}, req.query, { request: req });
342+
sendResult(result, res);
343+
} catch (err: any) {
344+
errorResponse(err, res);
345+
}
346+
});
347+
348+
server.patch(`${prefix}/cloud/environments/:id`, async (req: any, res: any) => {
349+
try {
350+
const result = await dispatcher.handleCloud(`/environments/${req.params.id}`, 'PATCH', req.body, {}, { request: req });
351+
sendResult(result, res);
352+
} catch (err: any) {
353+
errorResponse(err, res);
354+
}
355+
});
356+
357+
server.delete(`${prefix}/cloud/environments/:id`, async (req: any, res: any) => {
358+
try {
359+
const result = await dispatcher.handleCloud(`/environments/${req.params.id}`, 'DELETE', {}, {}, { request: req });
360+
sendResult(result, res);
361+
} catch (err: any) {
362+
errorResponse(err, res);
363+
}
364+
});
365+
366+
server.post(`${prefix}/cloud/environments/:id/rotate-credential`, async (req: any, res: any) => {
367+
try {
368+
const result = await dispatcher.handleCloud(`/environments/${req.params.id}/rotate-credential`, 'POST', req.body, {}, { request: req });
369+
sendResult(result, res);
370+
} catch (err: any) {
371+
errorResponse(err, res);
372+
}
373+
});
374+
375+
server.get(`${prefix}/cloud/environments/:id/members`, async (req: any, res: any) => {
376+
try {
377+
const result = await dispatcher.handleCloud(`/environments/${req.params.id}/members`, 'GET', {}, req.query, { request: req });
378+
sendResult(result, res);
379+
} catch (err: any) {
380+
errorResponse(err, res);
381+
}
382+
});
383+
320384
// ── Storage ─────────────────────────────────────────────────
321385
server.post(`${prefix}/storage/upload`, async (req: any, res: any) => {
322386
try {

packages/runtime/src/http-dispatcher.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,231 @@ export class HttpDispatcher {
936936
return { handled: false };
937937
}
938938

939+
/**
940+
* Cloud / Environment Control-Plane routes.
941+
*
942+
* - GET /cloud/environments → list
943+
* - POST /cloud/environments → provision
944+
* - GET /cloud/environments/:id → detail (+ db, credential, membership)
945+
* - PATCH /cloud/environments/:id → update displayName / plan / status / isDefault / metadata
946+
* - POST /cloud/environments/:id/activate → mark as active for session (stub)
947+
* - POST /cloud/environments/:id/credentials/rotate → rotate credential
948+
* - GET /cloud/environments/:id/members → list members
949+
*
950+
* Backed by ObjectQL sys__environment / sys__environment_database /
951+
* sys__database_credential / sys__environment_member tables (registered
952+
* by `@objectstack/service-tenant`'s `createTenantPlugin`).
953+
*/
954+
async handleCloud(path: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
955+
const m = method.toUpperCase();
956+
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
957+
958+
const qlService = await this.getObjectQLService();
959+
const ql = qlService ?? await this.resolveService('objectql');
960+
if (!ql) {
961+
return { handled: true, response: this.error('Environment service not available (ObjectQL missing)', 503) };
962+
}
963+
964+
const ENV = 'sys__environment';
965+
const DB = 'sys__environment_database';
966+
const CRED = 'sys__database_credential';
967+
const MEM = 'sys__environment_member';
968+
969+
const findOne = async (obj: string, where: Record<string, unknown>): Promise<any | undefined> => {
970+
let rows = await ql.find(obj, { where } as any);
971+
if (rows && (rows as any).value) rows = (rows as any).value;
972+
if (!Array.isArray(rows)) return undefined;
973+
return rows[0];
974+
};
975+
976+
try {
977+
// ----- /cloud/environments collection routes -----
978+
if (parts.length === 1 && parts[0] === 'environments' && m === 'GET') {
979+
const where: Record<string, unknown> = {};
980+
if (query?.organizationId) where.organization_id = query.organizationId;
981+
if (query?.envType) where.env_type = query.envType;
982+
if (query?.status) where.status = query.status;
983+
let rows = await ql.find(ENV, Object.keys(where).length ? ({ where } as any) : undefined);
984+
if (rows && (rows as any).value) rows = (rows as any).value;
985+
const environments = Array.isArray(rows) ? rows : [];
986+
return { handled: true, response: this.success({ environments, total: environments.length }) };
987+
}
988+
989+
if (parts.length === 1 && parts[0] === 'environments' && m === 'POST') {
990+
const req = body || {};
991+
if (!req.organizationId || !req.slug || !req.displayName || !req.envType) {
992+
return { handled: true, response: this.error('organizationId, slug, displayName, envType are required', 400) };
993+
}
994+
const environmentId = randomUUID();
995+
const environmentDatabaseId = randomUUID();
996+
const credentialId = randomUUID();
997+
const nowIso = new Date().toISOString();
998+
const driver = req.driver ?? 'turso';
999+
const region = req.region ?? 'us-east-1';
1000+
const databaseName = `env-${environmentId}`;
1001+
const databaseUrl = `libsql://${databaseName}.mock-${driver}.local`;
1002+
const plaintextSecret = `mock-token-${environmentId}`;
1003+
1004+
await ql.insert(ENV, {
1005+
id: environmentId,
1006+
organization_id: req.organizationId,
1007+
slug: req.slug,
1008+
display_name: req.displayName,
1009+
env_type: req.envType,
1010+
is_default: req.isDefault ?? false,
1011+
region,
1012+
plan: req.plan ?? 'free',
1013+
status: 'active',
1014+
created_by: req.createdBy ?? 'system',
1015+
metadata: req.metadata ? JSON.stringify(req.metadata) : null,
1016+
created_at: nowIso,
1017+
updated_at: nowIso,
1018+
});
1019+
1020+
await ql.insert(DB, {
1021+
id: environmentDatabaseId,
1022+
environment_id: environmentId,
1023+
database_name: databaseName,
1024+
database_url: databaseUrl,
1025+
driver,
1026+
region,
1027+
storage_limit_mb: req.storageLimitMb ?? 1024,
1028+
provisioned_at: nowIso,
1029+
created_at: nowIso,
1030+
updated_at: nowIso,
1031+
});
1032+
1033+
await ql.insert(CRED, {
1034+
id: credentialId,
1035+
environment_database_id: environmentDatabaseId,
1036+
secret_ciphertext: plaintextSecret,
1037+
encryption_key_id: 'noop',
1038+
authorization: 'full_access',
1039+
status: 'active',
1040+
created_at: nowIso,
1041+
updated_at: nowIso,
1042+
});
1043+
1044+
const environment = await findOne(ENV, { id: environmentId });
1045+
const database = await findOne(DB, { id: environmentDatabaseId });
1046+
const res = this.success({ environment, database });
1047+
res.status = 201;
1048+
return { handled: true, response: res };
1049+
}
1050+
1051+
// ----- /cloud/environments/:id -----
1052+
if (parts.length === 2 && parts[0] === 'environments') {
1053+
const id = decodeURIComponent(parts[1]);
1054+
1055+
if (m === 'GET') {
1056+
const environment = await findOne(ENV, { id });
1057+
if (!environment) return { handled: true, response: this.error(`Environment '${id}' not found`, 404) };
1058+
const database = await findOne(DB, { environment_id: id });
1059+
const credential = database
1060+
? await findOne(CRED, { environment_database_id: database.id, status: 'active' })
1061+
: undefined;
1062+
const membership = await findOne(MEM, { environment_id: id });
1063+
// Omit the ciphertext from responses — metadata only.
1064+
const credMeta = credential
1065+
? {
1066+
id: credential.id,
1067+
status: credential.status,
1068+
authorization: credential.authorization,
1069+
activatedAt: credential.created_at,
1070+
expiresAt: credential.expires_at,
1071+
}
1072+
: undefined;
1073+
return {
1074+
handled: true,
1075+
response: this.success({ environment, database, credential: credMeta, membership }),
1076+
};
1077+
}
1078+
1079+
if (m === 'PATCH') {
1080+
const patch: Record<string, unknown> = {};
1081+
if (body?.displayName !== undefined) patch.display_name = body.displayName;
1082+
if (body?.plan !== undefined) patch.plan = body.plan;
1083+
if (body?.status !== undefined) patch.status = body.status;
1084+
if (body?.isDefault !== undefined) patch.is_default = body.isDefault;
1085+
if (body?.metadata !== undefined) patch.metadata = JSON.stringify(body.metadata);
1086+
patch.updated_at = new Date().toISOString();
1087+
await ql.update(ENV, patch, { where: { id } } as any);
1088+
const environment = await findOne(ENV, { id });
1089+
if (!environment) return { handled: true, response: this.error(`Environment '${id}' not found`, 404) };
1090+
return { handled: true, response: this.success({ environment }) };
1091+
}
1092+
}
1093+
1094+
// ----- /cloud/environments/:id/activate -----
1095+
if (parts.length === 3 && parts[0] === 'environments' && parts[2] === 'activate' && m === 'POST') {
1096+
const id = decodeURIComponent(parts[1]);
1097+
const environment = await findOne(ENV, { id });
1098+
if (!environment) return { handled: true, response: this.error(`Environment '${id}' not found`, 404) };
1099+
// TODO: persist active_environment_id on the session once session service is wired.
1100+
return { handled: true, response: this.success({ environment, sessionUpdated: false }) };
1101+
}
1102+
1103+
// ----- /cloud/environments/:id/credentials/rotate -----
1104+
if (parts.length === 4 && parts[0] === 'environments' && parts[2] === 'credentials' && parts[3] === 'rotate' && m === 'POST') {
1105+
const id = decodeURIComponent(parts[1]);
1106+
const plaintext = body?.plaintext;
1107+
if (!plaintext || typeof plaintext !== 'string') {
1108+
return { handled: true, response: this.error('plaintext is required', 400) };
1109+
}
1110+
const database = await findOne(DB, { environment_id: id });
1111+
if (!database) return { handled: true, response: this.error(`No database for environment '${id}'`, 404) };
1112+
1113+
const nowIso = new Date().toISOString();
1114+
// Revoke existing active credentials
1115+
let existing = await ql.find(CRED, { where: { environment_database_id: database.id, status: 'active' } } as any);
1116+
if (existing && (existing as any).value) existing = (existing as any).value;
1117+
for (const row of (Array.isArray(existing) ? existing : [])) {
1118+
await ql.update(CRED, {
1119+
status: 'revoked',
1120+
revoked_at: nowIso,
1121+
updated_at: nowIso,
1122+
}, { where: { id: row.id } } as any);
1123+
}
1124+
1125+
const credentialId = randomUUID();
1126+
await ql.insert(CRED, {
1127+
id: credentialId,
1128+
environment_database_id: database.id,
1129+
secret_ciphertext: plaintext,
1130+
encryption_key_id: 'noop',
1131+
authorization: 'full_access',
1132+
status: 'active',
1133+
created_at: nowIso,
1134+
updated_at: nowIso,
1135+
});
1136+
1137+
const credential = await findOne(CRED, { id: credentialId });
1138+
const credMeta = credential
1139+
? {
1140+
id: credential.id,
1141+
status: credential.status,
1142+
authorization: credential.authorization,
1143+
activatedAt: credential.created_at,
1144+
}
1145+
: undefined;
1146+
return { handled: true, response: this.success({ credential: credMeta }) };
1147+
}
1148+
1149+
// ----- /cloud/environments/:id/members -----
1150+
if (parts.length === 3 && parts[0] === 'environments' && parts[2] === 'members' && m === 'GET') {
1151+
const id = decodeURIComponent(parts[1]);
1152+
let rows = await ql.find(MEM, { where: { environment_id: id } } as any);
1153+
if (rows && (rows as any).value) rows = (rows as any).value;
1154+
const members = Array.isArray(rows) ? rows : [];
1155+
return { handled: true, response: this.success({ members }) };
1156+
}
1157+
} catch (e: any) {
1158+
return { handled: true, response: this.error(e.message, e.statusCode || 500) };
1159+
}
1160+
1161+
return { handled: false };
1162+
}
1163+
9391164

9401165

9411166
/**
@@ -1370,6 +1595,10 @@ export class HttpDispatcher {
13701595
return this.handlePackages(cleanPath.substring(9), method, body, query, context);
13711596
}
13721597

1598+
if (cleanPath.startsWith('/cloud')) {
1599+
return this.handleCloud(cleanPath.substring(6), method, body, query, context);
1600+
}
1601+
13731602
if (cleanPath.startsWith('/i18n')) {
13741603
return this.handleI18n(cleanPath.substring(5), method, query, context);
13751604
}

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)