Skip to content

Commit b0017b4

Browse files
committed
feat(phase3): project-scoped URLs in Studio + CLI projects commands + RBAC
Phase 3 of the project-scoping rollout. Studio and the CLI now use the scoped URL shape end-to-end, and scoped data-plane routes enforce `sys_project_member` membership with a short-lived positive cache. Studio - New `useScopedClient(projectId)` hook returns a `ScopedProjectClient` bound to the current `/projects/$projectId` route param. - Migrated `ObjectDataTable`, `MetadataInspector`, `DeveloperOverview`, `app-sidebar`, and `projects.$projectId.packages.tsx` to hit `/api/v1/projects/:projectId/...` instead of the unscoped fallback. - Sidebar falls back to the unscoped client on pages with no project context (login, organization pages). Server - `apps/server/objectstack.config.ts` flips `api.enableProjectScoping` to `true` under `projectResolution: 'auto'` and registers `createSystemProjectPlugin()` so the well-known system project is provisioned on boot. - CLI `serve` forwards the stack's `api.enableProjectScoping` / `projectResolution` into both `createRestApiPlugin` and `createDispatcherPlugin`. CLI — `os projects` - `list` / `show` / `create` / `switch` sub-commands under `packages/cli/src/commands/projects/`, re-exported from index. - `create` activates the new project by default and persists `activeProjectId` into `~/.objectstack/credentials.json`. - `switch` verifies the project exists, optionally calls `/activate`, then writes the id. `createApiClient` reads it back so every subsequent command targets the right project. Runtime RBAC - New `HttpDispatcher.enforceProjectMembership(context, path)` runs after env resolution. Returns 403 for non-members; bypassed for the system project, platform-org members, and control-plane paths. - Positive results cached in-memory for 60s keyed by `(project, user)`. - Controlled by `DispatcherPluginConfig.enforceProjectMembership` (defaults to on when scoping is enabled). Tests - `http-dispatcher.test.ts` +5 RBAC cases (403, system-project bypass, platform-org bypass, positive-cache hit, enforcement disabled). - `commands/projects/projects.test.ts` smoke-checks command metadata. Verification - Chrome DevTools MCP smoke test confirmed Studio loads `/api/v1/projects/<system-uuid>/meta/*` for every metadata type after navigating to `/projects/$projectId/...`. - Discovery endpoint reports `scoping.enabled: true`. - All `@objectstack/runtime` (165), `@objectstack/client` (99), `@objectstack/rest` (46), and `@objectstack/cli` (89) tests pass.
1 parent 07e9be5 commit b0017b4

20 files changed

Lines changed: 803 additions & 44 deletions

apps/server/objectstack.config.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010

1111
import { defineStack } from '@objectstack/spec';
12-
import { AppPlugin, DriverPlugin } from '@objectstack/runtime';
12+
import { AppPlugin, DriverPlugin, createSystemProjectPlugin } from '@objectstack/runtime';
1313
import { ObjectQLPlugin } from '@objectstack/objectql';
1414
import { InMemoryDriver } from '@objectstack/driver-memory';
1515
import { TursoDriver } from '@objectstack/driver-turso';
@@ -63,6 +63,12 @@ export default defineStack({
6363
description: 'Production server aggregating CRM, Todo and BI plugins',
6464
type: 'app',
6565
},
66+
// Phase 3: enable project-scoped URLs (/api/v1/projects/:projectId/...)
67+
// under 'auto' resolution so legacy unscoped routes continue to work.
68+
api: {
69+
enableProjectScoping: true,
70+
projectResolution: 'auto',
71+
},
6672
plugins: [
6773
oqlPlugin,
6874
// Set datasourceMapping right after ObjectQL init — access ql instance directly
@@ -77,6 +83,9 @@ export default defineStack({
7783
new DriverPlugin(tursoDriver, 'turso'),
7884
new PackageServicePlugin(), // Package management service
7985
createTenantPlugin({ registerSystemObjects: true, registerLegacyTenantDatabase: false }),
86+
// Provisions the well-known system project (00000000-0000-0000-0000-000000000001)
87+
// on startup. Idempotent — safe to register unconditionally.
88+
createSystemProjectPlugin(),
8089
new AppPlugin(CrmApp),
8190
new AppPlugin(TodoApp),
8291
new AppPlugin(BiPluginManifest),

apps/studio/src/components/DeveloperOverview.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { useState, useEffect, useCallback } from 'react';
4-
import { useClient } from '@objectstack/client-react';
4+
import { useParams } from '@tanstack/react-router';
5+
import { useScopedClient } from '@/hooks/useObjectStackClient';
56
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
67
import { Badge } from "@/components/ui/badge";
78
import { Button } from "@/components/ui/button";
@@ -25,14 +26,16 @@ interface SystemStats {
2526
}
2627

2728
export function DeveloperOverview({ packages, selectedPackage, onNavigate }: DeveloperOverviewProps) {
28-
const client = useClient();
29+
const params = useParams({ strict: false }) as { projectId?: string };
30+
const client = useScopedClient(params.projectId);
2931
const [stats, setStats] = useState<SystemStats>({
3032
packages: { total: 0, enabled: 0, disabled: 0, byType: {} },
3133
metadata: { types: [], counts: {} },
3234
loading: true,
3335
});
3436

3537
const loadStats = useCallback(async () => {
38+
if (!client) return;
3639
setStats(prev => ({ ...prev, loading: true }));
3740
try {
3841
// Fetch metadata types

apps/studio/src/components/MetadataInspector.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { useState, useEffect } from 'react';
4-
import { useClient } from '@objectstack/client-react';
4+
import { useParams } from '@tanstack/react-router';
5+
import { useScopedClient } from '@/hooks/useObjectStackClient';
56
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
67
import { Badge } from "@/components/ui/badge";
78
import { Input } from "@/components/ui/input";
@@ -191,7 +192,8 @@ function JsonTree({ data, depth = 0 }: { data: any; depth?: number }) {
191192

192193

193194
export function MetadataInspector({ metaType, metaName, packageId }: MetadataInspectorProps) {
194-
const client = useClient();
195+
const params = useParams({ strict: false }) as { projectId?: string };
196+
const client = useScopedClient(params.projectId);
195197
const [item, setItem] = useState<any>(null);
196198
const [loading, setLoading] = useState(true);
197199
const [searchQuery, setSearchQuery] = useState('');
@@ -200,13 +202,14 @@ export function MetadataInspector({ metaType, metaName, packageId }: MetadataIns
200202
const typeLabel = TYPE_LABELS[metaType] || metaType.charAt(0).toUpperCase() + metaType.slice(1);
201203

202204
useEffect(() => {
205+
if (!client) return;
203206
let mounted = true;
204207
setLoading(true);
205208
setItem(null);
206209

207210
async function load() {
208211
try {
209-
const result: any = await client.meta.getItem(metaType, metaName, packageId ? { packageId } : undefined);
212+
const result: any = await client!.meta.getItem(metaType, metaName, packageId ? { packageId } : undefined);
210213
if (mounted) {
211214
setItem(result?.item || result);
212215
}

apps/studio/src/components/ObjectDataTable.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { useState, useEffect } from 'react';
4-
import { useClient } from '@objectstack/client-react';
4+
import { useParams } from '@tanstack/react-router';
5+
import { useScopedClient } from '@/hooks/useObjectStackClient';
56
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
67
import { Button } from "@/components/ui/button";
78
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@@ -80,7 +81,8 @@ function TableSkeleton({ cols }: { cols: number }) {
8081
}
8182

8283
export function ObjectDataTable({ objectApiName, onEdit, refreshTrigger = 0 }: ObjectDataTableProps) {
83-
const client = useClient();
84+
const params = useParams({ strict: false }) as { projectId?: string };
85+
const client = useScopedClient(params.projectId);
8486
const [def, setDef] = useState<any>(null);
8587
const [records, setRecords] = useState<any[]>([]);
8688
const [loading, setLoading] = useState(false);
@@ -91,10 +93,11 @@ export function ObjectDataTable({ objectApiName, onEdit, refreshTrigger = 0 }: O
9193

9294
// Load Definition
9395
useEffect(() => {
96+
if (!client) return;
9497
let mounted = true;
9598
async function loadDef() {
9699
try {
97-
const found: any = await client.meta.getItem('object', objectApiName);
100+
const found: any = await client!.meta.getItem('object', objectApiName);
98101
if (mounted && found) {
99102
// Spec: GetMetaItemResponse = { type, name, item }
100103
const def = found.item || found;
@@ -110,11 +113,12 @@ export function ObjectDataTable({ objectApiName, onEdit, refreshTrigger = 0 }: O
110113

111114
// Load Data
112115
useEffect(() => {
116+
if (!client) return;
113117
let mounted = true;
114118
async function loadData() {
115119
setLoading(true);
116120
try {
117-
const result: any = await client.data.find(objectApiName, {
121+
const result: any = await client!.data.find(objectApiName, {
118122
filters: {
119123
top: pageSize,
120124
skip: (page - 1) * pageSize,
@@ -140,6 +144,7 @@ export function ObjectDataTable({ objectApiName, onEdit, refreshTrigger = 0 }: O
140144
}, [client, objectApiName, page, refreshTrigger]);
141145

142146
async function handleDelete(id: string) {
147+
if (!client) return;
143148
if (!confirm('Are you sure you want to delete this record?')) return;
144149
try {
145150
await client.data.delete(objectApiName, id);
@@ -161,6 +166,7 @@ export function ObjectDataTable({ objectApiName, onEdit, refreshTrigger = 0 }: O
161166
}
162167

163168
async function handleRefresh() {
169+
if (!client) return;
164170
setLoading(true);
165171
try {
166172
const result: any = await client.data.find(objectApiName, {

apps/studio/src/components/app-sidebar.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
import { useState, useEffect, useCallback, useMemo } from "react"
3737
import { useNavigate, useParams, useLocation } from '@tanstack/react-router';
3838
import { useClient, useMetadataSubscriptionCallback } from '@objectstack/client-react';
39+
import { useScopedClient } from '@/hooks/useObjectStackClient';
3940
import type { InstalledPackage } from '@objectstack/spec/kernel';
4041

4142
import {
@@ -166,11 +167,17 @@ export function AppSidebar({
166167
packages, selectedPackage, onSelectPackage, projectId,
167168
...props
168169
}: AppSidebarProps) {
169-
const client = useClient();
170+
const unscopedClient = useClient();
170171
const navigate = useNavigate();
171-
const params = useParams({ strict: false });
172+
const params = useParams({ strict: false }) as any;
172173
const location = useLocation();
173174

175+
// Prefer a project-scoped client when a projectId is present in the URL
176+
// (e.g. under /projects/$projectId/...). Falls back to the unscoped client
177+
// during login / organization pages that have no project context yet.
178+
const scopedClient = useScopedClient(params.projectId);
179+
const client = scopedClient ?? unscopedClient;
180+
174181
// Extract current selection from URL params
175182
const selectedObject = params.name && params.package && !params.type ? params.name : null;
176183
const selectedMeta = params.type && params.name ? { type: params.type, name: params.name } : null;

apps/studio/src/hooks/useObjectStackClient.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { useState, useEffect } from 'react';
4-
import { ObjectStackClient } from '@objectstack/client';
3+
import { useMemo, useState, useEffect } from 'react';
4+
import { ObjectStackClient, type ScopedProjectClient } from '@objectstack/client';
5+
import { useClient } from '@objectstack/client-react';
56
import { getApiBaseUrl, config } from '../lib/config';
67
import { recallActiveProject } from './useProjects';
78

@@ -27,3 +28,25 @@ export function useObjectStackClient() {
2728

2829
return client;
2930
}
31+
32+
/**
33+
* Hook that returns a {@link ScopedProjectClient} bound to the given
34+
* `projectId`. When `projectId` is `undefined` or an empty string, the hook
35+
* returns `null` so callers can defer network calls until the route is fully
36+
* resolved.
37+
*
38+
* The scoped client routes every request through
39+
* `/api/v1/projects/:projectId/...`, which is the canonical URL shape once
40+
* `enableProjectScoping` is enabled on the server. The dual-mode routing in
41+
* Phase 2 keeps the unscoped routes working under `projectResolution: 'auto'`,
42+
* so this migration is safe to ship incrementally.
43+
*/
44+
export function useScopedClient(
45+
projectId: string | undefined,
46+
): ScopedProjectClient | null {
47+
const client = useClient();
48+
return useMemo(() => {
49+
if (!client || !projectId) return null;
50+
return client.project(projectId);
51+
}, [client, projectId]);
52+
}

apps/studio/src/routes/projects.$projectId.packages.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router';
1212
import { useState, useEffect, useCallback } from 'react';
13-
import { useClient } from '@objectstack/client-react';
13+
import { useScopedClient } from '@/hooks/useObjectStackClient';
1414
import { Package, Power, PowerOff, Trash2, Plus, RefreshCw, ArrowRight } from 'lucide-react';
1515
import { Card, CardContent } from '@/components/ui/card';
1616
import { Badge } from '@/components/ui/badge';
@@ -22,7 +22,7 @@ import type { InstalledPackage } from '@objectstack/spec/kernel';
2222

2323
function ProjectPackagesComponent() {
2424
const { projectId } = useParams({ from: '/projects/$projectId' });
25-
const client = useClient() as any;
25+
const client = useScopedClient(projectId) as any;
2626
const navigate = useNavigate();
2727

2828
const { packages: installedPkgs, loading, error, install, uninstall, enable, disable, reload } =
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { Command, Flags } from '@oclif/core';
4+
import { printError } from '../../utils/format.js';
5+
import { createApiClient, requireAuth } from '../../utils/api-client.js';
6+
import { formatOutput } from '../../utils/output-formatter.js';
7+
import { readAuthConfig, writeAuthConfig } from '../../utils/auth-config.js';
8+
9+
/**
10+
* `os projects create` — provision a new project.
11+
*
12+
* Delegates to `ProjectProvisioningService.provisionProject` on the server.
13+
* On success, optionally activates the new project for the current session
14+
* and persists `activeProjectId` into `~/.objectstack/credentials.json`
15+
* (unless `--no-activate` is passed).
16+
*/
17+
export default class ProjectsCreate extends Command {
18+
static override description = 'Provision a new project';
19+
20+
static override examples = [
21+
'$ os projects create --org 00000000-0000-0000-0000-000000000000 --name Staging',
22+
'$ os projects create --org $ORG --name Dev --plan free',
23+
'$ os projects create --org $ORG --name "Clone" --clone-from <source-id> --no-activate',
24+
];
25+
26+
static override flags = {
27+
url: Flags.string({ char: 'u', description: 'Server URL', env: 'OBJECTSTACK_URL' }),
28+
token: Flags.string({ char: 't', description: 'Authentication token', env: 'OBJECTSTACK_TOKEN' }),
29+
org: Flags.string({ description: 'Organization id', required: true }),
30+
name: Flags.string({ description: 'Display name', required: true }),
31+
plan: Flags.string({ description: 'Billing plan', default: 'free' }),
32+
driver: Flags.string({ description: 'Data-plane driver id' }),
33+
'clone-from': Flags.string({ description: 'Clone schema from an existing project id' }),
34+
activate: Flags.boolean({
35+
description: 'Activate the new project for subsequent CLI calls',
36+
default: true,
37+
allowNo: true,
38+
}),
39+
format: Flags.string({
40+
char: 'f',
41+
description: 'Output format',
42+
options: ['json', 'table', 'yaml'],
43+
default: 'table',
44+
}),
45+
};
46+
47+
async run(): Promise<void> {
48+
const { flags } = await this.parse(ProjectsCreate);
49+
50+
try {
51+
const { client, token } = await createApiClient({ url: flags.url, token: flags.token });
52+
requireAuth(token);
53+
54+
const res = await client.projects.create({
55+
organizationId: flags.org,
56+
displayName: flags.name,
57+
plan: flags.plan,
58+
driver: flags.driver,
59+
cloneFromProjectId: flags['clone-from'],
60+
});
61+
62+
if (flags.activate && res?.project?.id) {
63+
try {
64+
await client.projects.activate(res.project.id);
65+
const cfg = await readAuthConfig().catch(() => null);
66+
if (cfg) {
67+
cfg.activeProjectId = res.project.id;
68+
cfg.lastUsedAt = new Date().toISOString();
69+
await writeAuthConfig(cfg);
70+
}
71+
} catch (activateError: any) {
72+
// Creation succeeded — surface activation failure as a warning
73+
console.error(` ⚠ activation failed: ${activateError.message}`);
74+
}
75+
}
76+
77+
if (flags.format === 'json') {
78+
formatOutput(res, 'json');
79+
} else if (flags.format === 'yaml') {
80+
formatOutput(res, 'yaml');
81+
} else {
82+
const p = res?.project ?? {};
83+
console.log(`\n✓ Project created: ${p.displayName ?? p.id} (${p.id})`);
84+
if (flags.activate) {
85+
console.log(` active project set to ${p.id}`);
86+
}
87+
console.log('');
88+
}
89+
} catch (error: any) {
90+
if (flags.format === 'json') {
91+
console.log(JSON.stringify({ success: false, error: error.message }, null, 2));
92+
this.exit(1);
93+
}
94+
printError(error.message || String(error));
95+
this.exit(1);
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)