diff --git a/docs/design/SYSTEM_ARCHITECTURE_IMPLEMENTATION.md b/docs/design/SYSTEM_ARCHITECTURE_IMPLEMENTATION.md new file mode 100644 index 000000000..f7e827fc5 --- /dev/null +++ b/docs/design/SYSTEM_ARCHITECTURE_IMPLEMENTATION.md @@ -0,0 +1,373 @@ +# System Architecture Implementation Summary + +**Status**: Phase 1 Complete ✅ +**Date**: 2026-04-22 +**Branch**: `claude/design-new-system-architecture` + +## Overview + +This document summarizes the implementation of ObjectStack's new system architecture featuring a built-in "system" project and project-scoped API routing configuration, following Airtable's workspace/base scoping model. + +## What Has Been Implemented + +### 1. System Project Schema & Infrastructure ✅ + +#### Schema Changes +- **Added `isSystem` field to `ProjectSchema`** (`packages/spec/src/cloud/project.zod.ts`) + - Type: `z.boolean().default(false)` + - Distinguishes system projects from user projects + - Default `false` for regular projects + +- **Added `is_system` field to `sys_project` object** (`packages/services/service-tenant/src/objects/sys-project.object.ts`) + - Field type: `Field.boolean()` + - Required: `true` + - Default: `false` + +#### System Project Provisioning +Implemented `ProjectProvisioningService.provisionSystemProject()` method: + +```typescript +// Well-known UUIDs +const SYSTEM_PROJECT_ID = '00000000-0000-0000-0000-000000000001'; +const PLATFORM_ORG_ID = '00000000-0000-0000-0000-000000000000'; + +// System project characteristics: +{ + id: SYSTEM_PROJECT_ID, + organizationId: PLATFORM_ORG_ID, + slug: 'system', + displayName: 'System', + projectType: 'production', + isDefault: false, + isSystem: true, + plan: 'enterprise', + hostname: 'system.objectstack.internal', + // Uses control plane DB - no separate physical database + databaseUrl: undefined, + databaseDriver: undefined, + storageLimitMb: undefined +} +``` + +**Key Features:** +- Idempotent provisioning (returns existing if already created) +- Operates on control plane database +- Protected from deletion +- Hosts system-level packages and plugins + +### 2. Project-Scoped Routing Configuration ✅ + +#### REST API Configuration Schema +Added to `RestApiConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`): + +```typescript +{ + // Enable project-scoped routing + enableProjectScoping: z.boolean().default(false) + .describe('Enable project-scoped routing for data/meta/AI APIs'), + + // Project resolution strategy + projectResolution: z.enum(['required', 'optional', 'auto']).default('auto') + .describe('Project ID resolution strategy') +} +``` + +**Resolution Strategies:** +- `required`: projectId must be in URL (strict, recommended for production) +- `optional`: projectId can be in URL or fallback to headers/session +- `auto`: backward compatible - accepts both scoped and unscoped routes + +#### Proposed Routing Structure +``` +Control Plane APIs (unscoped): +├── /api/v1/auth/* +├── /api/v1/cloud/projects +├── /api/v1/cloud/organizations +└── /api/v1/health + +Project-Scoped Data APIs: +├── /api/v1/projects/:projectId/data/:object +├── /api/v1/projects/:projectId/meta +├── /api/v1/projects/:projectId/packages +├── /api/v1/projects/:projectId/ai/* +├── /api/v1/projects/:projectId/automation/* +└── /api/v1/projects/:projectId/analytics/* + +Backward Compatibility (deprecated): +├── /api/v1/data/:object +└── /api/v1/meta/:type +``` + +### 3. Comprehensive Test Coverage ✅ + +Created `packages/services/service-tenant/src/project-provisioning.test.ts` with 7 passing tests: + +**Regular Project Tests:** +1. ✅ Returns fully-formed project with `isSystem=false` in detached mode +2. ✅ Persists control plane rows with all fields including `is_system` +3. ✅ Rejects second default project for same organization + +**System Project Tests:** +4. ✅ Creates system project with well-known UUID +5. ✅ Persists system project to control plane with correct fields +6. ✅ Returns existing system project if already created (idempotent) +7. ✅ System project metadata contains expected values + +**Test Results:** +``` +Test Files 1 passed (1) +Tests 7 passed (7) +Duration 516ms +``` + +### 4. Build Verification ✅ + +All modified packages build successfully: +- ✅ `@objectstack/spec` - Schema package with new fields +- ✅ `@objectstack/service-tenant` - Provisioning service with system project support + +## Files Modified + +1. **`packages/spec/src/cloud/project.zod.ts`** + - Added `isSystem` field to ProjectSchema + +2. **`packages/spec/src/api/rest-server.zod.ts`** + - Added `enableProjectScoping` and `projectResolution` fields + +3. **`packages/services/service-tenant/src/objects/sys-project.object.ts`** + - Added `is_system` field definition + +4. **`packages/services/service-tenant/src/project-provisioning.ts`** + - Implemented `provisionSystemProject()` method + - Fixed `isSystem` field in regular project provisioning + - Added `is_system` to database persistence + +5. **`packages/services/service-tenant/src/project-provisioning.test.ts`** (NEW) + - Comprehensive test suite for project provisioning + +## Architecture Benefits + +### System Project Separation +- **Clear Isolation**: System infrastructure separate from user data +- **Security**: System project protected with `isSystem` flag +- **Maintenance**: Easy identification of platform vs application packages +- **Scalability**: Platform can evolve independently of user projects + +### Project-Scoped APIs +- **Multi-tenancy**: Clear project boundaries in API design +- **Industry Alignment**: Follows Airtable/Salesforce patterns +- **Future-proof**: Enables per-project quotas, permissions, billing +- **Backward Compatible**: 'auto' strategy maintains existing behavior + +## What Remains for Future Implementation + +### Phase 2: Runtime Implementation +1. **REST Server Route Registration** + - Implement dual route registration (scoped and unscoped) + - Add middleware for project context resolution + - Update route handlers to accept projectId parameter + +2. **HTTP Dispatcher Updates** + - Extract projectId from URL params + - Validate user has access to project + - Resolve project's database connection + - Add project context to execution context + +3. **Client SDK** + - Implement `client.projects(id).data.find()` + - Maintain backward compatibility with `client.data.find()` + - Add project switching utilities + +4. **Integration Testing** + - Live server tests with project-scoped routes + - Backward compatibility tests + - Project access control tests + +5. **Browser E2E Testing** + - Studio UI project selection + - API calls with project context + - Multi-project workflows + +## Migration Path + +### For Existing Deployments + +**Step 1**: Deploy schema changes (Current) +- System project schema available +- No breaking changes + +**Step 2**: Provision system project (Manual or automatic on startup) +```typescript +const provisioning = new ProjectProvisioningService({ controlPlaneDriver }); +await provisioning.provisionSystemProject(); +``` + +**Step 3**: Enable project-scoped routing (Future) +```typescript +// In objectstack.config.ts +{ + api: { + enableProjectScoping: true, + projectResolution: 'auto' // Start with backward compatibility + } +} +``` + +**Step 4**: Migrate system packages to system project (Future) +- Update package installations to reference system project +- Verify system packages load correctly + +**Step 5**: Enable strict mode (Future, optional) +```typescript +{ + api: { + enableProjectScoping: true, + projectResolution: 'required' // Enforce project IDs in URLs + } +} +``` + +## Usage Examples + +### Provisioning System Project + +```typescript +import { ProjectProvisioningService } from '@objectstack/service-tenant'; + +const service = new ProjectProvisioningService({ + controlPlaneDriver: myDriver, + defaultRegion: 'us-east-1', +}); + +// Idempotent - safe to call multiple times +const result = await service.provisionSystemProject(); + +console.log(result.project.id); // '00000000-0000-0000-0000-000000000001' +console.log(result.project.isSystem); // true +``` + +### Checking if Project is System Project + +```typescript +import { ProjectSchema } from '@objectstack/spec/cloud'; + +const project = await getProject(projectId); + +if (project.isSystem) { + console.log('This is a system project - protected'); + // Disallow deletion, enforce special permissions, etc. +} +``` + +### Future: Using Project-Scoped APIs + +```typescript +// When Phase 2 is complete: + +// Project-scoped API call +const tasks = await client + .projects('proj-123') + .data.find('task', { where: { status: 'open' } }); + +// Backward compatible (uses default project) +const tasks = await client + .data.find('task', { where: { status: 'open' } }); +``` + +## Testing Approach + +### Current Test Coverage +- ✅ Unit tests for schema validation +- ✅ Unit tests for provisioning service +- ✅ Unit tests for idempotent behavior +- ✅ Unit tests for error cases + +### Future Test Coverage (Phase 2) +- [ ] Integration tests with live HTTP server +- [ ] API tests for project-scoped routes +- [ ] Backward compatibility tests +- [ ] Project access control tests +- [ ] Browser E2E tests in Studio + +## Performance Considerations + +### System Project +- **No Additional Overhead**: Uses existing control plane database +- **Fast Lookup**: Well-known UUID enables direct queries +- **No Network Calls**: No separate database provisioning + +### Project-Scoped Routing (Future) +- **Caching Strategy**: Cache project metadata to avoid DB lookups per request +- **Connection Pooling**: Reuse database connections per project +- **Lazy Loading**: Only resolve project when needed + +## Security Considerations + +### System Project Protection +- `isSystem` flag prevents accidental deletion +- Should enforce read-only access for non-admin users +- System packages cannot be uninstalled by regular users + +### Project-Scoped APIs (Future) +- RBAC checks must validate user access to project +- Project ID in URL prevents confused deputy attacks +- Each project's data isolated in separate database + +## Benchmarking Against Industry Standards + +### Airtable +- ✅ Workspace/Base scoping model → Our Project scoping +- ✅ API routes include resource IDs → `/projects/:projectId/...` +- ✅ Metadata separation → System project vs user projects + +### Salesforce +- ✅ Sandboxes/Orgs → Our Projects +- ✅ System objects vs custom → System project flag +- ✅ Organization-scoped APIs → Project-scoped APIs + +### Power Platform +- ✅ Environments → Our Projects +- ✅ System solutions vs custom → System project +- ✅ Environment routing → Project routing + +## Documentation Updates Needed + +When Phase 2 is implemented: + +1. **API Documentation** + - Update endpoint documentation with project-scoped routes + - Add migration guide from unscoped to scoped + - Document project resolution strategies + +2. **Developer Guides** + - How to work with system project + - How to provision new projects + - How to use project-scoped client SDK + +3. **Architecture Documentation** + - Update ADR with project-scoped routing decision + - Document project isolation model + - Security model for multi-project access + +## Conclusion + +**Phase 1 Implementation: Complete ✅** + +This implementation delivers a solid, tested foundation for ObjectStack's new system architecture: +- ✅ Schema changes are production-ready +- ✅ System project provisioning is idempotent and tested +- ✅ Configuration for project-scoped routing is in place +- ✅ All code builds and tests pass + +**Next Steps:** +Phase 2 (Runtime Implementation) can be tackled in future sprints with confidence that the foundation is solid and well-tested. + +**Estimated Effort:** +- Phase 1 (Complete): ~50% of total architectural change +- Phase 2 (Remaining): ~50% - Runtime implementation, testing, documentation + +This phased approach ensures: +1. Non-breaking schema evolution +2. Incremental deployment capability +3. Ability to validate architecture before full commitment +4. Clear rollback path if needed diff --git a/packages/client/src/client.project-scoping.test.ts b/packages/client/src/client.project-scoping.test.ts new file mode 100644 index 000000000..71c843aef --- /dev/null +++ b/packages/client/src/client.project-scoping.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Integration test — end-to-end project-scoped REST routing. + * + * Boots a real Hono server, wires up ObjectQL + createRestApiPlugin with + * `enableProjectScoping: true` / `projectResolution: 'auto'`, and verifies: + * 1. Scoped `/api/v1/projects/:id/data/:object` works. + * 2. Unscoped `/api/v1/data/:object` still works (backward compat). + * 3. Scoped meta routes return metadata. + * 4. `projectResolution: 'required'` mode rejects unscoped requests. + * + * Uses the same LiteKernel + HonoServerPlugin pattern as client.hono.test.ts. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin, SchemaRegistry } from '@objectstack/objectql'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { createRestApiPlugin } from '@objectstack/runtime'; +import { ObjectStackClient } from './index'; + +describe('Project-scoped REST routing (live Hono)', () => { + let baseUrl: string; + let kernel: LiteKernel; + + beforeAll(async () => { + kernel = new LiteKernel(); + kernel.use(new ObjectQLPlugin()); + + const honoPlugin = new HonoServerPlugin({ + port: 0, + // IMPORTANT: skip hardcoded hono CRUD routes so createRestApiPlugin + // owns /data and /meta registration end-to-end. + registerStandardEndpoints: false, + }); + kernel.use(honoPlugin); + + // Drive REST route generation through the canonical RestServer so + // the new enableProjectScoping / projectResolution fields are + // actually consumed at runtime. + kernel.use( + createRestApiPlugin({ + api: { + api: { + enableProjectScoping: true, + projectResolution: 'auto', + } as any, + }, + }), + ); + + await kernel.bootstrap(); + + const ql = kernel.getService('objectql'); + ql.registerDriver(new InMemoryDriver(), true); + + SchemaRegistry.registerObject({ + name: 'task', + label: 'Task', + fields: { + title: { type: 'text', label: 'Title' }, + }, + }); + + const httpServer = kernel.getService('http.server'); + const port = httpServer.getPort(); + baseUrl = `http://localhost:${port}`; + }, 30_000); + + afterAll(async () => { + if (kernel) { + await Promise.race([ + kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); + } + }, 30_000); + + it('serves scoped CRUD at /api/v1/projects/:projectId/data/:object', async () => { + const res = await fetch( + `${baseUrl}/api/v1/projects/proj-alpha/data/task?top=5`, + ); + expect(res.status).toBe(200); + const body = await res.json(); + // The response shape is controlled by the protocol's findData — + // the key assertion is that the route resolved (not 404) and the + // handler ran. + expect(body).toBeDefined(); + }); + + it('serves unscoped CRUD at /api/v1/data/:object (backward compat in auto mode)', async () => { + const res = await fetch(`${baseUrl}/api/v1/data/task?top=5`); + expect(res.status).toBe(200); + }); + + it('serves scoped metadata at /api/v1/projects/:projectId/meta', async () => { + const res = await fetch(`${baseUrl}/api/v1/projects/proj-alpha/meta`); + expect(res.status).toBe(200); + }); + + it('client.project(id).data.find() hits the scoped URL end-to-end', async () => { + const client = new ObjectStackClient({ baseUrl }); + const scoped = client.project('proj-alpha'); + // Should resolve without throwing — the route must exist on the server. + await expect(scoped.data.find('task')).resolves.toBeDefined(); + }); + + it("scoping flags are surfaced on the discovery response", async () => { + const res = await fetch(`${baseUrl}/api/v1/discovery`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body?.scoping?.enabled).toBe(true); + expect(body?.scoping?.resolution).toBe('auto'); + }); +}); diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index ae2a781bd..892fde44a 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -889,3 +889,79 @@ describe('QueryBuilder — offset() alias', () => { expect(q.offset).toBe(30); }); }); + +// ---------------------------------------------------------------------- +// ScopedProjectClient — project-scoped sub-client (Phase 2) +// ---------------------------------------------------------------------- + +describe('ScopedProjectClient', () => { + it('prefixes meta.getTypes with /projects/:id', async () => { + const { client, fetchMock } = createMockClient({ types: ['object'] }); + const scoped = client.project('proj-123'); + await scoped.meta.getTypes(); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:3000/api/v1/projects/proj-123/meta', + expect.any(Object), + ); + }); + + it('prefixes data.find with /projects/:id', async () => { + const { client, fetchMock } = createMockClient({ records: [] }); + const scoped = client.project('proj-123'); + await scoped.data.find('task', { top: 5 }); + const url = (fetchMock.mock.calls[0] as any[])[0] as string; + expect(url.startsWith('http://localhost:3000/api/v1/projects/proj-123/data/task')).toBe(true); + expect(url).toContain('top=5'); + }); + + it('prefixes data.get / data.create / data.update / data.delete', async () => { + const { client, fetchMock } = createMockClient({ id: 't1' }); + const scoped = client.project('proj-xyz'); + + await scoped.data.get('task', 't1'); + expect(fetchMock).toHaveBeenLastCalledWith( + 'http://localhost:3000/api/v1/projects/proj-xyz/data/task/t1', + expect.any(Object), + ); + + await scoped.data.create('task', { title: 'hi' }); + expect(fetchMock).toHaveBeenLastCalledWith( + 'http://localhost:3000/api/v1/projects/proj-xyz/data/task', + expect.objectContaining({ method: 'POST' }), + ); + + await scoped.data.update('task', 't1', { title: 'ok' }); + expect(fetchMock).toHaveBeenLastCalledWith( + 'http://localhost:3000/api/v1/projects/proj-xyz/data/task/t1', + expect.objectContaining({ method: 'PATCH' }), + ); + + await scoped.data.delete('task', 't1'); + expect(fetchMock).toHaveBeenLastCalledWith( + 'http://localhost:3000/api/v1/projects/proj-xyz/data/task/t1', + expect.objectContaining({ method: 'DELETE' }), + ); + }); + + it('url-encodes the projectId', async () => { + const { client, fetchMock } = createMockClient({ types: [] }); + const scoped = client.project('proj with space'); + await scoped.meta.getTypes(); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:3000/api/v1/projects/proj%20with%20space/meta', + expect.any(Object), + ); + }); + + it('throws when projectId is missing', () => { + const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000' }); + // @ts-expect-error — empty string rejected at runtime + expect(() => client.project('')).toThrow(/projectId is required/); + }); + + it('exposes projectId via getProjectId()', () => { + const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000' }); + const scoped = client.project('00000000-0000-0000-0000-000000000001'); + expect(scoped.getProjectId()).toBe('00000000-0000-0000-0000-000000000001'); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 9c318af48..15dc3407b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -784,6 +784,46 @@ export class ObjectStackClient { }, }; + /** + * Project-scoped client factory. + * + * Returns a thin wrapper around the data / meta / packages namespaces that + * prefixes every request with `/api/v1/projects/:projectId/...`. Use this + * when the server has `enableProjectScoping: true` in its REST API config. + * + * Backward compatibility: `client.data.*`, `client.meta.*`, and + * `client.packages.*` continue to work unchanged; they hit unscoped routes + * and rely on hostname / `X-Project-Id` header / session resolution. + * + * @example + * ```ts + * const scoped = client.project('00000000-0000-0000-0000-000000000001'); + * const tasks = await scoped.data.find('task', { top: 10 }); + * const objects = await scoped.meta.getItems('object'); + * ``` + */ + project(projectId: string): ScopedProjectClient { + if (!projectId) { + throw new Error('[ObjectStack] project(id): projectId is required'); + } + return new ScopedProjectClient(this, projectId); + } + + // ── Internal accessors exposed to ScopedProjectClient ──────────────── + // The scoped client lives in the same module so using module-level access + // works; TypeScript requires these to be accessible, so we expose them via + // small protected getters that keep the public surface unchanged. + /** @internal */ + _baseUrl(): string { return this.baseUrl; } + /** @internal */ + _fetch(url: string, init?: RequestInit): Promise { + return this.fetch(url, init); + } + /** @internal */ + _unwrap(res: Response): Promise { return this.unwrapResponse(res); } + /** @internal */ + _isFilterAST(v: unknown): boolean { return this.isFilterAST(v); } + /** * Organization Services * @@ -2141,6 +2181,222 @@ export class ObjectStackClient { } } +/** + * Project-scoped sub-client. + * + * Wraps an {@link ObjectStackClient} and prefixes every request with + * `/api/v1/projects/:projectId/...` so a single client instance can talk to + * multiple projects without mutating global state. + * + * The scoped client exposes the same shape as the `data`, `meta`, `batch`, + * and `packages` namespaces on `ObjectStackClient` — only the URL prefix + * differs. The server-side dual-mode route registration (see + * `packages/rest/src/rest-server.ts`) accepts both shapes when + * `projectResolution` is `'auto'` or `'optional'`. + */ +export class ScopedProjectClient { + private readonly parent: ObjectStackClient; + private readonly projectId: string; + + constructor(parent: ObjectStackClient, projectId: string) { + this.parent = parent; + this.projectId = projectId; + } + + /** The projectId this client is scoped to. */ + getProjectId(): string { return this.projectId; } + + /** Prefix segment inserted between the baseUrl and the resource path. */ + private scope(): string { return `/api/v1/projects/${encodeURIComponent(this.projectId)}`; } + + private url(suffix: string): string { + return `${this.parent._baseUrl()}${this.scope()}${suffix}`; + } + + /** + * Metadata operations scoped to this project. + */ + meta = { + getTypes: async (): Promise => { + const res = await this.parent._fetch(this.url('/meta')); + return this.parent._unwrap(res); + }, + getItems: async (type: string, options?: { packageId?: string }): Promise => { + const params = new URLSearchParams(); + if (options?.packageId) params.set('package', options.packageId); + const qs = params.toString(); + const res = await this.parent._fetch(this.url(`/meta/${type}${qs ? `?${qs}` : ''}`)); + return this.parent._unwrap(res); + }, + getItem: async (type: string, name: string, options?: { packageId?: string }) => { + const params = new URLSearchParams(); + if (options?.packageId) params.set('package', options.packageId); + const qs = params.toString(); + const res = await this.parent._fetch(this.url(`/meta/${type}/${name}${qs ? `?${qs}` : ''}`)); + return this.parent._unwrap(res); + }, + saveItem: async (type: string, name: string, item: any) => { + const res = await this.parent._fetch(this.url(`/meta/${type}/${name}`), { + method: 'PUT', + body: JSON.stringify(item), + }); + return this.parent._unwrap(res); + }, + deleteItem: async (type: string, name: string): Promise<{ type: string; name: string; deleted: boolean }> => { + const res = await this.parent._fetch(this.url(`/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}`), { + method: 'DELETE', + }); + return this.parent._unwrap(res); + }, + }; + + /** + * Data operations scoped to this project. + * + * Mirrors the query / find / get / create / update / delete / batch + * surface on {@link ObjectStackClient}. URL construction differs only + * in the prefix — query parameter serialization is identical. + */ + data = { + query: async (object: string, query: Partial): Promise> => { + const res = await this.parent._fetch(this.url(`/data/${object}/query`), { + method: 'POST', + body: JSON.stringify(query), + }); + return this.parent._unwrap>(res); + }, + find: async (object: string, options: QueryOptions | QueryOptionsV2 = {}): Promise> => { + const queryParams = new URLSearchParams(); + + const v2 = options as QueryOptionsV2; + const normalizedOptions: QueryOptions = {} as QueryOptions; + if ('where' in options || 'fields' in options || 'orderBy' in options || 'offset' in options) { + if (v2.where) normalizedOptions.filter = v2.where as any; + if (v2.fields) normalizedOptions.select = v2.fields; + if (v2.orderBy) normalizedOptions.sort = v2.orderBy as any; + if (v2.limit != null) normalizedOptions.top = v2.limit; + if (v2.offset != null) normalizedOptions.skip = v2.offset; + if (v2.aggregations) normalizedOptions.aggregations = v2.aggregations; + if (v2.groupBy) normalizedOptions.groupBy = v2.groupBy; + } else { + Object.assign(normalizedOptions, options); + } + + if (normalizedOptions.top) queryParams.set('top', normalizedOptions.top.toString()); + if (normalizedOptions.skip) queryParams.set('skip', normalizedOptions.skip.toString()); + if (normalizedOptions.sort) { + if (Array.isArray(normalizedOptions.sort) && typeof normalizedOptions.sort[0] === 'object') { + queryParams.set('sort', JSON.stringify(normalizedOptions.sort)); + } else { + const sortVal = Array.isArray(normalizedOptions.sort) ? normalizedOptions.sort.join(',') : normalizedOptions.sort; + queryParams.set('sort', sortVal as string); + } + } + if (normalizedOptions.select) { + queryParams.set('select', normalizedOptions.select.join(',')); + } + const filterValue = normalizedOptions.filter ?? normalizedOptions.filters; + if (filterValue) { + if (this.parent._isFilterAST(filterValue) || Array.isArray(filterValue)) { + queryParams.set('filter', JSON.stringify(filterValue)); + } else if (typeof filterValue === 'object' && filterValue !== null) { + Object.entries(filterValue as Record).forEach(([k, v]) => { + if (v !== undefined && v !== null) { + queryParams.append(k, String(v)); + } + }); + } + } + if (normalizedOptions.aggregations) { + queryParams.set('aggregations', JSON.stringify(normalizedOptions.aggregations)); + } + if (normalizedOptions.groupBy) { + queryParams.set('groupBy', normalizedOptions.groupBy.join(',')); + } + + const qs = queryParams.toString(); + const res = await this.parent._fetch(this.url(`/data/${object}${qs ? `?${qs}` : ''}`)); + return this.parent._unwrap>(res); + }, + get: async (object: string, id: string): Promise> => { + const res = await this.parent._fetch(this.url(`/data/${object}/${id}`)); + return this.parent._unwrap>(res); + }, + create: async (object: string, data: Partial): Promise> => { + const res = await this.parent._fetch(this.url(`/data/${object}`), { + method: 'POST', + body: JSON.stringify(data), + }); + return this.parent._unwrap>(res); + }, + createMany: async (object: string, data: Partial[]): Promise => { + const res = await this.parent._fetch(this.url(`/data/${object}/createMany`), { + method: 'POST', + body: JSON.stringify(data), + }); + return this.parent._unwrap(res); + }, + update: async (object: string, id: string, data: Partial): Promise> => { + const res = await this.parent._fetch(this.url(`/data/${object}/${id}`), { + method: 'PATCH', + body: JSON.stringify(data), + }); + return this.parent._unwrap>(res); + }, + batch: async (object: string, request: BatchUpdateRequest): Promise => { + const res = await this.parent._fetch(this.url(`/data/${object}/batch`), { + method: 'POST', + body: JSON.stringify(request), + }); + return this.parent._unwrap(res); + }, + updateMany: async ( + object: string, + records: Array<{ id: string; data: Partial }>, + options?: BatchOptions, + ): Promise => { + const request: UpdateManyRequest = { records, options }; + const res = await this.parent._fetch(this.url(`/data/${object}/updateMany`), { + method: 'POST', + body: JSON.stringify(request), + }); + return this.parent._unwrap(res); + }, + delete: async (object: string, id: string): Promise => { + const res = await this.parent._fetch(this.url(`/data/${object}/${id}`), { + method: 'DELETE', + }); + return this.parent._unwrap(res); + }, + deleteMany: async (object: string, ids: string[], options?: BatchOptions): Promise => { + const request: DeleteManyRequest = { ids, options }; + const res = await this.parent._fetch(this.url(`/data/${object}/deleteMany`), { + method: 'POST', + body: JSON.stringify(request), + }); + return this.parent._unwrap(res); + }, + }; + + /** + * Package management scoped to this project. + * Only the read-path is exposed here — publish / delete remain on the + * global `client.packages` namespace for now, pending dedicated per-project + * package tests. + */ + packages = { + list: async (): Promise<{ packages: any[]; total: number }> => { + const res = await this.parent._fetch(this.url('/packages')); + return this.parent._unwrap<{ packages: any[]; total: number }>(res); + }, + get: async (id: string, version?: string) => { + const qs = version ? `?version=${encodeURIComponent(version)}` : ''; + const res = await this.parent._fetch(this.url(`/packages/${encodeURIComponent(id)}${qs}`)); + return this.parent._unwrap<{ package: any }>(res); + }, + }; +} + // Re-export type-safe query builder export { QueryBuilder, FilterBuilder, createQuery, createFilter } from './query-builder'; diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 467dc88e9..3ee674b1b 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -76,9 +76,23 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { if (packageService) { const basePath = config.api?.api?.basePath || '/api'; const version = config.api?.api?.version || 'v1'; - registerPackageRoutes(server, packageService, `${basePath}/${version}`, { - protocol, - }); + const versionedBase = `${basePath}/${version}`; + const enableProjectScoping = config.api?.api?.enableProjectScoping ?? false; + const projectResolution = config.api?.api?.projectResolution ?? 'auto'; + + if (enableProjectScoping && projectResolution === 'required') { + // Only register the scoped variant + registerPackageRoutes(server, packageService, `${versionedBase}/projects/:projectId`, { + protocol, + }); + } else { + registerPackageRoutes(server, packageService, versionedBase, { protocol }); + if (enableProjectScoping) { + registerPackageRoutes(server, packageService, `${versionedBase}/projects/:projectId`, { + protocol, + }); + } + } ctx.logger.info('Package management routes registered'); } } catch (e) { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 4ae11a701..a77b63da2 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -19,6 +19,8 @@ type NormalizedRestServerConfig = { enableUi: boolean; enableBatch: boolean; enableDiscovery: boolean; + enableProjectScoping: boolean; + projectResolution: 'required' | 'optional' | 'auto'; documentation: RestApiConfig['documentation']; responseFormat: RestApiConfig['responseFormat']; }; @@ -126,6 +128,8 @@ export class RestServer { enableUi: api.enableUi ?? true, enableBatch: api.enableBatch ?? true, enableDiscovery: api.enableDiscovery ?? true, + enableProjectScoping: api.enableProjectScoping ?? false, + projectResolution: api.projectResolution ?? 'auto', documentation: api.documentation, responseFormat: api.responseFormat, }, @@ -179,36 +183,58 @@ export class RestServer { const { api } = this.config; return api.apiPath ?? `${api.basePath}/${api.version}`; } - + + /** + * Get the project-scoped base path for a given unscoped base. + * Example: `/api/v1` → `/api/v1/projects/:projectId`. + */ + private getScopedBasePath(basePath: string): string { + return `${basePath}/projects/:projectId`; + } + /** * Register all REST API routes + * + * When `enableProjectScoping` is true, routes are registered under + * `/api/v1/projects/:projectId/...`. The `projectResolution` strategy + * controls whether unscoped legacy routes remain available: + * - `required` → only scoped routes registered. + * - `optional` / `auto` → both scoped and unscoped routes registered. */ registerRoutes(): void { const basePath = this.getApiBasePath(); - - // Discovery endpoint - if (this.config.api.enableDiscovery) { - this.registerDiscoveryEndpoints(basePath); - } - - // Metadata endpoints - if (this.config.api.enableMetadata) { - this.registerMetadataEndpoints(basePath); - } + const { enableProjectScoping, projectResolution } = this.config.api; - // UI endpoints - if (this.config.api.enableUi) { - this.registerUiEndpoints(basePath); - } - - // CRUD endpoints - if (this.config.api.enableCrud) { - this.registerCrudEndpoints(basePath); - } - - // Batch endpoints - if (this.config.api.enableBatch) { - this.registerBatchEndpoints(basePath); + const registerForBase = (bp: string) => { + if (this.config.api.enableDiscovery) { + this.registerDiscoveryEndpoints(bp); + } + if (this.config.api.enableMetadata) { + this.registerMetadataEndpoints(bp); + } + if (this.config.api.enableUi) { + this.registerUiEndpoints(bp); + } + if (this.config.api.enableCrud) { + this.registerCrudEndpoints(bp); + } + if (this.config.api.enableBatch) { + this.registerBatchEndpoints(bp); + } + }; + + if (enableProjectScoping) { + const scopedBase = this.getScopedBasePath(basePath); + if (projectResolution === 'required') { + // Strict: only scoped routes + registerForBase(scopedBase); + } else { + // 'optional' | 'auto' — keep both so legacy callers keep working + registerForBase(basePath); + registerForBase(scopedBase); + } + } else { + registerForBase(basePath); } } @@ -216,33 +242,52 @@ export class RestServer { * Register discovery endpoints */ private registerDiscoveryEndpoints(basePath: string): void { - const discoveryHandler = async (_req: any, res: any) => { + const isScoped = basePath.includes('/projects/:projectId'); + const discoveryHandler = async (req: any, res: any) => { try { const discovery = await this.protocol.getDiscovery(); - + // Override discovery information with actual server configuration discovery.version = this.config.api.version; - + + // Substitute the resolved projectId into the advertised routes so + // clients can consume them verbatim (e.g. /api/v1/projects/abc/data). + const realBase = isScoped + ? basePath.replace(':projectId', req.params?.projectId ?? ':projectId') + : basePath; + if (discovery.routes) { // Ensure routes match the actual mounted paths if (this.config.api.enableCrud) { - discovery.routes.data = `${basePath}${this.config.crud.dataPrefix}`; + discovery.routes.data = `${realBase}${this.config.crud.dataPrefix}`; } - + if (this.config.api.enableMetadata) { - discovery.routes.metadata = `${basePath}${this.config.metadata.prefix}`; + discovery.routes.metadata = `${realBase}${this.config.metadata.prefix}`; } if (this.config.api.enableUi) { - discovery.routes.ui = `${basePath}/ui`; + discovery.routes.ui = `${realBase}/ui`; } - // Align auth route with the versioned base path if present + // Align auth route with the versioned base path if present. + // Auth is a control-plane concern, so use the unscoped base. if (discovery.routes.auth) { - discovery.routes.auth = `${basePath}/auth`; + const unscopedBase = isScoped + ? basePath.replace(/\/projects\/:projectId$/, '') + : basePath; + discovery.routes.auth = `${unscopedBase}/auth`; } } + // Attach scoping metadata so clients can detect dual-mode routing. + (discovery as any).scoping = { + enabled: this.config.api.enableProjectScoping, + resolution: this.config.api.projectResolution, + scoped: isScoped, + projectId: isScoped ? req.params?.projectId : undefined, + }; + res.json(discovery); } catch (error: any) { res.status(500).json({ error: error.message }); @@ -278,15 +323,19 @@ export class RestServer { private registerMetadataEndpoints(basePath: string): void { const { metadata } = this.config; const metaPath = `${basePath}${metadata.prefix}`; - + const isScoped = basePath.includes('/projects/:projectId'); + // GET /meta - List all metadata types if (metadata.endpoints.types !== false) { this.routeManager.register({ method: 'GET', path: metaPath, - handler: async (_req: any, res: any) => { + handler: async (req: any, res: any) => { try { - const types = await this.protocol.getMetaTypes(); + const projectId = isScoped ? req.params?.projectId : undefined; + const types = await this.protocol.getMetaTypes( + projectId ? ({ projectId } as any) : undefined, + ); res.json(types); } catch (error: any) { res.status(500).json({ error: error.message }); @@ -298,7 +347,7 @@ export class RestServer { }, }); } - + // GET /meta/:type - List items of a type if (metadata.endpoints.items !== false) { this.routeManager.register({ @@ -307,7 +356,12 @@ export class RestServer { handler: async (req: any, res: any) => { try { const packageId = req.query?.package || undefined; - const items = await this.protocol.getMetaItems({ type: req.params.type, packageId }); + const projectId = isScoped ? req.params?.projectId : undefined; + const items = await this.protocol.getMetaItems({ + type: req.params.type, + packageId, + ...(projectId ? { projectId } : {}), + } as any); res.json(items); } catch (error: any) { res.status(404).json({ error: error.message }); @@ -319,7 +373,7 @@ export class RestServer { }, }); } - + // GET /meta/:type/:name - Get specific item if (metadata.endpoints.item !== false) { this.routeManager.register({ @@ -327,28 +381,30 @@ export class RestServer { path: `${metaPath}/:type/:name`, handler: async (req: any, res: any) => { try { + const projectId = isScoped ? req.params?.projectId : undefined; // Check if cached version is available if (metadata.enableCache && this.protocol.getMetaItemCached) { const cacheRequest = { ifNoneMatch: req.headers['if-none-match'] as string, ifModifiedSince: req.headers['if-modified-since'] as string, }; - + const result = await this.protocol.getMetaItemCached({ type: req.params.type, name: req.params.name, - cacheRequest - }); - + cacheRequest, + ...(projectId ? { projectId } : {}), + } as any); + if (result.notModified) { res.status(304).send(); return; } - + // Set cache headers if (result.etag) { - const etagValue = result.etag.weak - ? `W/"${result.etag.value}"` + const etagValue = result.etag.weak + ? `W/"${result.etag.value}"` : `"${result.etag.value}"`; res.header('ETag', etagValue); } @@ -357,17 +413,22 @@ export class RestServer { } if (result.cacheControl) { const directives = result.cacheControl.directives.join(', '); - const maxAge = result.cacheControl.maxAge - ? `, max-age=${result.cacheControl.maxAge}` + const maxAge = result.cacheControl.maxAge + ? `, max-age=${result.cacheControl.maxAge}` : ''; res.header('Cache-Control', directives + maxAge); } - + res.json(result.data); } else { // Non-cached version const packageId = req.query?.package || undefined; - const item = await this.protocol.getMetaItem({ type: req.params.type, name: req.params.name, packageId }); + const item = await this.protocol.getMetaItem({ + type: req.params.type, + name: req.params.name, + packageId, + ...(projectId ? { projectId } : {}), + } as any); res.json(item); } } catch (error: any) { @@ -394,11 +455,13 @@ export class RestServer { return; } + const projectId = isScoped ? req.params?.projectId : undefined; const result = await this.protocol.saveMetaItem({ type: req.params.type, name: req.params.name, - item: req.body - }); + item: req.body, + ...(projectId ? { projectId } : {}), + } as any); res.json(result); } catch (error: any) { res.status(400).json({ error: error.message }); @@ -416,7 +479,8 @@ export class RestServer { */ private registerUiEndpoints(basePath: string): void { const uiPath = `${basePath}/ui`; - + const isScoped = basePath.includes('/projects/:projectId'); + // GET /ui/view/:object/:type - Resolve view for object this.routeManager.register({ method: 'GET', @@ -424,10 +488,12 @@ export class RestServer { handler: async (req: any, res: any) => { try { if (this.protocol.getUiView) { - const view = await this.protocol.getUiView({ - object: req.params.object, - type: req.params.type as any - }); + const projectId = isScoped ? req.params?.projectId : undefined; + const view = await this.protocol.getUiView({ + object: req.params.object, + type: req.params.type as any, + ...(projectId ? { projectId } : {}), + } as any); res.json(view); } else { res.status(501).json({ error: 'UI View resolution not supported by protocol implementation' }); @@ -449,9 +515,10 @@ export class RestServer { private registerCrudEndpoints(basePath: string): void { const { crud } = this.config; const dataPath = `${basePath}${crud.dataPrefix}`; - + const isScoped = basePath.includes('/projects/:projectId'); + const operations = crud.operations; - + // GET /data/:object - List/query records if (operations.list) { this.routeManager.register({ @@ -459,10 +526,12 @@ export class RestServer { path: `${dataPath}/:object`, handler: async (req: any, res: any) => { try { + const projectId = isScoped ? req.params?.projectId : undefined; const result = await this.protocol.findData({ - object: req.params.object, - query: req.query - }); + object: req.params.object, + query: req.query, + ...(projectId ? { projectId } : {}), + } as any); res.json(result); } catch (error: any) { res.status(400).json({ error: error.message }); @@ -474,7 +543,7 @@ export class RestServer { }, }); } - + // GET /data/:object/:id - Get single record if (operations.read) { this.routeManager.register({ @@ -482,13 +551,15 @@ export class RestServer { path: `${dataPath}/:object/:id`, handler: async (req: any, res: any) => { try { + const projectId = isScoped ? req.params?.projectId : undefined; const { select, expand } = req.query || {}; const result = await this.protocol.getData({ - object: req.params.object, + object: req.params.object, id: req.params.id, ...(select != null ? { select } : {}), ...(expand != null ? { expand } : {}), - }); + ...(projectId ? { projectId } : {}), + } as any); res.json(result); } catch (error: any) { res.status(404).json({ error: error.message }); @@ -500,7 +571,7 @@ export class RestServer { }, }); } - + // POST /data/:object - Create record if (operations.create) { this.routeManager.register({ @@ -508,10 +579,12 @@ export class RestServer { path: `${dataPath}/:object`, handler: async (req: any, res: any) => { try { + const projectId = isScoped ? req.params?.projectId : undefined; const result = await this.protocol.createData({ - object: req.params.object, - data: req.body - }); + object: req.params.object, + data: req.body, + ...(projectId ? { projectId } : {}), + } as any); res.status(201).json(result); } catch (error: any) { res.status(400).json({ error: error.message }); @@ -523,7 +596,7 @@ export class RestServer { }, }); } - + // PATCH /data/:object/:id - Update record if (operations.update) { this.routeManager.register({ @@ -531,11 +604,13 @@ export class RestServer { path: `${dataPath}/:object/:id`, handler: async (req: any, res: any) => { try { + const projectId = isScoped ? req.params?.projectId : undefined; const result = await this.protocol.updateData({ object: req.params.object, id: req.params.id, - data: req.body - }); + data: req.body, + ...(projectId ? { projectId } : {}), + } as any); res.json(result); } catch (error: any) { res.status(400).json({ error: error.message }); @@ -547,7 +622,7 @@ export class RestServer { }, }); } - + // DELETE /data/:object/:id - Delete record if (operations.delete) { this.routeManager.register({ @@ -555,10 +630,12 @@ export class RestServer { path: `${dataPath}/:object/:id`, handler: async (req: any, res: any) => { try { + const projectId = isScoped ? req.params?.projectId : undefined; const result = await this.protocol.deleteData({ - object: req.params.object, - id: req.params.id - }); + object: req.params.object, + id: req.params.id, + ...(projectId ? { projectId } : {}), + } as any); res.json(result); } catch (error: any) { res.status(400).json({ error: error.message }); @@ -578,9 +655,10 @@ export class RestServer { private registerBatchEndpoints(basePath: string): void { const { crud, batch } = this.config; const dataPath = `${basePath}${crud.dataPrefix}`; - + const isScoped = basePath.includes('/projects/:projectId'); + const operations = batch.operations; - + // POST /data/:object/batch - Generic batch endpoint if (batch.enableBatchEndpoint && this.protocol.batchData) { this.routeManager.register({ @@ -588,10 +666,12 @@ export class RestServer { path: `${dataPath}/:object/batch`, handler: async (req: any, res: any) => { try { + const projectId = isScoped ? req.params?.projectId : undefined; const result = await this.protocol.batchData!({ - object: req.params.object, - request: req.body - }); + object: req.params.object, + request: req.body, + ...(projectId ? { projectId } : {}), + } as any); res.json(result); } catch (error: any) { res.status(400).json({ error: error.message }); @@ -603,7 +683,7 @@ export class RestServer { }, }); } - + // POST /data/:object/createMany - Bulk create if (operations.createMany && this.protocol.createManyData) { this.routeManager.register({ @@ -611,10 +691,12 @@ export class RestServer { path: `${dataPath}/:object/createMany`, handler: async (req: any, res: any) => { try { + const projectId = isScoped ? req.params?.projectId : undefined; const result = await this.protocol.createManyData!({ object: req.params.object, - records: req.body || [] - }); + records: req.body || [], + ...(projectId ? { projectId } : {}), + } as any); res.status(201).json(result); } catch (error: any) { res.status(400).json({ error: error.message }); @@ -626,7 +708,7 @@ export class RestServer { }, }); } - + // POST /data/:object/updateMany - Bulk update if (operations.updateMany && this.protocol.updateManyData) { this.routeManager.register({ @@ -634,10 +716,12 @@ export class RestServer { path: `${dataPath}/:object/updateMany`, handler: async (req: any, res: any) => { try { + const projectId = isScoped ? req.params?.projectId : undefined; const result = await this.protocol.updateManyData!({ object: req.params.object, - ...req.body - }); + ...req.body, + ...(projectId ? { projectId } : {}), + } as any); res.json(result); } catch (error: any) { res.status(400).json({ error: error.message }); @@ -649,7 +733,7 @@ export class RestServer { }, }); } - + // POST /data/:object/deleteMany - Bulk delete if (operations.deleteMany && this.protocol.deleteManyData) { this.routeManager.register({ @@ -657,10 +741,12 @@ export class RestServer { path: `${dataPath}/:object/deleteMany`, handler: async (req: any, res: any) => { try { + const projectId = isScoped ? req.params?.projectId : undefined; const result = await this.protocol.deleteManyData!({ - object: req.params.object, - ...req.body - }); + object: req.params.object, + ...req.body, + ...(projectId ? { projectId } : {}), + } as any); res.json(result); } catch (error: any) { res.status(400).json({ error: error.message }); diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index a9c783970..ef60f67e6 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -670,3 +670,96 @@ describe('createRestApiPlugin', () => { }); }); }); + +// --------------------------------------------------------------------------- +// RestServer — project-scoped routing (Phase 2) +// --------------------------------------------------------------------------- + +describe('RestServer project-scoped routing', () => { + it('only registers unscoped routes by default', () => { + const server = createMockServer(); + const protocol = createMockProtocol(); + const rest = new RestServer(server as any, protocol as any); + rest.registerRoutes(); + + const paths = rest.getRoutes().map(r => r.path); + expect(paths).toContain('/api/v1/data/:object'); + expect(paths.some(p => p.includes('/projects/:projectId'))).toBe(false); + }); + + it("registers both unscoped and scoped routes in 'auto' mode", () => { + const server = createMockServer(); + const protocol = createMockProtocol(); + const rest = new RestServer(server as any, protocol as any, { + api: { enableProjectScoping: true, projectResolution: 'auto' } as any, + } as any); + rest.registerRoutes(); + + const paths = rest.getRoutes().map(r => r.path); + expect(paths).toContain('/api/v1/data/:object'); + expect(paths).toContain('/api/v1/projects/:projectId/data/:object'); + expect(paths).toContain('/api/v1/meta'); + expect(paths).toContain('/api/v1/projects/:projectId/meta'); + }); + + it("only registers scoped routes in 'required' mode", () => { + const server = createMockServer(); + const protocol = createMockProtocol(); + const rest = new RestServer(server as any, protocol as any, { + api: { enableProjectScoping: true, projectResolution: 'required' } as any, + } as any); + rest.registerRoutes(); + + const paths = rest.getRoutes().map(r => r.path); + expect(paths).toContain('/api/v1/projects/:projectId/data/:object'); + expect(paths).not.toContain('/api/v1/data/:object'); + }); + + it('scoped CRUD handler forwards req.params.projectId into the protocol call', async () => { + const server = createMockServer(); + const protocol = createMockProtocol(); + const rest = new RestServer(server as any, protocol as any, { + api: { enableProjectScoping: true, projectResolution: 'required' } as any, + } as any); + rest.registerRoutes(); + + const listRoute = rest + .getRoutes() + .find(r => r.path === '/api/v1/projects/:projectId/data/:object' && r.method === 'GET'); + expect(listRoute).toBeDefined(); + + const res = { json: vi.fn(), status: vi.fn().mockReturnThis() }; + await listRoute!.handler( + { params: { projectId: 'proj-123', object: 'task' }, query: {} }, + res, + ); + + expect(protocol.findData).toHaveBeenCalledWith( + expect.objectContaining({ object: 'task', projectId: 'proj-123' }), + ); + }); + + it('unscoped handler in auto mode does NOT set projectId on the protocol call', async () => { + const server = createMockServer(); + const protocol = createMockProtocol(); + const rest = new RestServer(server as any, protocol as any, { + api: { enableProjectScoping: true, projectResolution: 'auto' } as any, + } as any); + rest.registerRoutes(); + + const unscoped = rest + .getRoutes() + .find(r => r.path === '/api/v1/data/:object' && r.method === 'GET'); + expect(unscoped).toBeDefined(); + + const res = { json: vi.fn(), status: vi.fn().mockReturnThis() }; + await unscoped!.handler( + { params: { object: 'task' }, query: {} }, + res, + ); + + expect(protocol.findData).toHaveBeenCalledWith( + expect.not.objectContaining({ projectId: expect.anything() }), + ); + }); +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 4c091b2cb..189cc2319 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -9,6 +9,21 @@ export interface DispatcherPluginConfig { * @default '/api/v1' */ prefix?: string; + + /** + * Project-scoping configuration. Must match the REST API + * `enableProjectScoping` / `projectResolution` fields so AI / automation + * routes stay in lockstep with /data and /meta. + * + * When `enableProjectScoping` is true and `projectResolution` is: + * - `required` — only `/projects/:projectId/...` variants are registered. + * - `optional` / `auto` — both unscoped and scoped variants are registered + * (the scoped handler forwards `req.params.projectId` into context). + */ + scoping?: { + enableProjectScoping?: boolean; + projectResolution?: 'required' | 'optional' | 'auto'; + }; } /** @@ -533,112 +548,160 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu }); // ── Automation ────────────────────────────────────────────── - server.get(`${prefix}/automation`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleAutomation('', 'GET', {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.post(`${prefix}/automation`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleAutomation('', 'POST', req.body, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.get(`${prefix}/automation/:name`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleAutomation(`${req.params.name}`, 'GET', {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.put(`${prefix}/automation/:name`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleAutomation(`${req.params.name}`, 'PUT', req.body, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.delete(`${prefix}/automation/:name`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleAutomation(`${req.params.name}`, 'DELETE', {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.post(`${prefix}/automation/trigger/:name`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleAutomation(`trigger/${req.params.name}`, 'POST', req.body, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.post(`${prefix}/automation/:name/trigger`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleAutomation(`${req.params.name}/trigger`, 'POST', req.body, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.post(`${prefix}/automation/:name/toggle`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleAutomation(`${req.params.name}/toggle`, 'POST', req.body, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); + // Registered at both `${prefix}/automation/...` and + // `${prefix}/projects/:projectId/automation/...` when project + // scoping is enabled. Handlers surface `req.params.projectId` to + // the HttpDispatcher through the `request` context so downstream + // resolution (see HttpDispatcher.resolveEnvironmentContext) can + // pick the right data driver. + const registerAutomationRoutes = (base: string) => { + server!.get(`${base}/automation`, async (req: any, res: any) => { + try { + const result = await dispatcher.handleAutomation('', 'GET', {}, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + + server!.post(`${base}/automation`, async (req: any, res: any) => { + try { + const result = await dispatcher.handleAutomation('', 'POST', req.body, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + + server!.get(`${base}/automation/:name`, async (req: any, res: any) => { + try { + const result = await dispatcher.handleAutomation(`${req.params.name}`, 'GET', {}, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + + server!.put(`${base}/automation/:name`, async (req: any, res: any) => { + try { + const result = await dispatcher.handleAutomation(`${req.params.name}`, 'PUT', req.body, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + + server!.delete(`${base}/automation/:name`, async (req: any, res: any) => { + try { + const result = await dispatcher.handleAutomation(`${req.params.name}`, 'DELETE', {}, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + + server!.post(`${base}/automation/trigger/:name`, async (req: any, res: any) => { + try { + const result = await dispatcher.handleAutomation(`trigger/${req.params.name}`, 'POST', req.body, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + + server!.post(`${base}/automation/:name/trigger`, async (req: any, res: any) => { + try { + const result = await dispatcher.handleAutomation(`${req.params.name}/trigger`, 'POST', req.body, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + + server!.post(`${base}/automation/:name/toggle`, async (req: any, res: any) => { + try { + const result = await dispatcher.handleAutomation(`${req.params.name}/toggle`, 'POST', req.body, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + + server!.get(`${base}/automation/:name/runs`, async (req: any, res: any) => { + try { + const result = await dispatcher.handleAutomation(`${req.params.name}/runs`, 'GET', {}, { request: req }, req.query); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + + server!.get(`${base}/automation/:name/runs/:runId`, async (req: any, res: any) => { + try { + const result = await dispatcher.handleAutomation(`${req.params.name}/runs/${req.params.runId}`, 'GET', {}, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + }; - server.get(`${prefix}/automation/:name/runs`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleAutomation(`${req.params.name}/runs`, 'GET', {}, { request: req }, req.query); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); + const enableProjectScoping = config.scoping?.enableProjectScoping ?? false; + const projectResolution = config.scoping?.projectResolution ?? 'auto'; - server.get(`${prefix}/automation/:name/runs/:runId`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleAutomation(`${req.params.name}/runs/${req.params.runId}`, 'GET', {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); + if (enableProjectScoping && projectResolution === 'required') { + registerAutomationRoutes(`${prefix}/projects/:projectId`); + } else { + registerAutomationRoutes(prefix); + if (enableProjectScoping) { + registerAutomationRoutes(`${prefix}/projects/:projectId`); } - }); + } - ctx.logger.info('Dispatcher bridge routes registered', { prefix }); + ctx.logger.info('Dispatcher bridge routes registered', { prefix, enableProjectScoping, projectResolution }); // ── Dynamic service routes (AI, etc.) ─────────────────── // Listen for route definitions emitted by service plugins. // The AIServicePlugin emits 'ai:routes' with RouteDefinition[]. + // + // When project-scoping is enabled, each AI route is mounted on + // BOTH `${prefix}${path}` and `${prefix}/projects/:projectId${path}` + // (or only the scoped variant when `projectResolution === 'required'`). + const toScopedPath = (routePath: string): string => { + // routePath may already include /api/v1; splice /projects/:projectId + // after the `${prefix}` portion to produce the scoped variant. + if (routePath.startsWith(prefix)) { + const tail = routePath.slice(prefix.length); + return `${prefix}/projects/:projectId${tail}`; + } + return `/projects/:projectId${routePath}`; + }; + + const mountAiRoute = (route: RouteDefinition) => { + if (!server) return 0; + const routePath = route.path.startsWith('/api/v1') + ? route.path + : `${prefix}${route.path}`; + + let count = 0; + if (enableProjectScoping && projectResolution === 'required') { + if (mountRouteOnServer(route, server, toScopedPath(routePath))) count++; + } else { + if (mountRouteOnServer(route, server, routePath)) count++; + if (enableProjectScoping) { + if (mountRouteOnServer(route, server, toScopedPath(routePath))) count++; + } + } + return count; + }; + ctx.hook('ai:routes', async (routes: RouteDefinition[]) => { if (!server) return; + let total = 0; for (const route of routes) { - // Strip the /api/v1 prefix if present (it's already in the path) - // and register on the HTTP server with the configured prefix. - const routePath = route.path.startsWith('/api/v1') - ? route.path - : `${prefix}${route.path}`; - mountRouteOnServer(route, server, routePath); + total += mountAiRoute(route); } - ctx.logger.info(`[Dispatcher] Registered ${routes.length} AI routes`); + ctx.logger.info(`[Dispatcher] Registered ${total} AI route mount(s) from ${routes.length} definition(s)`); }); // ── Fallback: recover routes cached before hook was registered ── @@ -652,15 +715,10 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu if (cachedRoutes && Array.isArray(cachedRoutes) && cachedRoutes.length > 0) { let registered = 0; for (const route of cachedRoutes) { - const routePath = route.path.startsWith('/api/v1') - ? route.path - : `${prefix}${route.path}`; - if (mountRouteOnServer(route, server, routePath)) { - registered++; - } + registered += mountAiRoute(route); } if (registered > 0) { - ctx.logger.info(`[Dispatcher] Recovered ${registered} cached AI routes (hook timing fallback)`); + ctx.logger.info(`[Dispatcher] Recovered ${registered} cached AI route mount(s) (hook timing fallback)`); } } }, diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index f5a4f9918..888c6a64b 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -1314,4 +1314,70 @@ describe('HttpDispatcher', () => { expect(result.response?.body?.data?.types).toContain('custom_type'); }); }); + + // ------------------------------------------------------------------ + // Phase 2 — URL-param project resolution + // ------------------------------------------------------------------ + describe('resolveEnvironmentContext — URL-param projectId', () => { + let envDispatcher: HttpDispatcher; + let envRegistry: any; + + beforeEach(() => { + envRegistry = { + resolveByHostname: vi.fn().mockResolvedValue(null), + resolveById: vi.fn(), + }; + envDispatcher = new HttpDispatcher(kernel, envRegistry); + }); + + it('resolves projectId from /projects/:id/... path before hostname / header', async () => { + envRegistry.resolveById = vi.fn().mockResolvedValue({ name: 'driver-for-proj-123' }); + + const context: any = { request: { headers: { host: 'anyhost' } } }; + // Access the private resolver through a public entry point: handleData + // triggers resolveEnvironmentContext with the given path. + await (envDispatcher as any).resolveEnvironmentContext( + context, + '/api/v1/projects/proj-123/data/task', + ); + + expect(envRegistry.resolveById).toHaveBeenCalledWith('proj-123'); + expect(context.projectId).toBe('proj-123'); + expect(context.dataDriver).toEqual({ name: 'driver-for-proj-123' }); + // Hostname path should NOT have been tried. + expect(envRegistry.resolveByHostname).not.toHaveBeenCalled(); + }); + + it('does not treat /cloud/projects/:id as a scoping prefix', async () => { + envRegistry.resolveById = vi.fn().mockResolvedValue({ name: 'wrong' }); + + const context: any = { request: { headers: {} } }; + await (envDispatcher as any).resolveEnvironmentContext( + context, + '/api/v1/cloud/projects/proj-123', + ); + + // /cloud is explicitly skipped. + expect(envRegistry.resolveById).not.toHaveBeenCalled(); + expect(context.projectId).toBeUndefined(); + }); + + it('falls through to header resolution when URL-param project is unknown', async () => { + envRegistry.resolveById = vi.fn() + .mockResolvedValueOnce(null) // URL-param lookup fails + .mockResolvedValueOnce({ name: 'header-driver' }); // header lookup succeeds + + const context: any = { + request: { headers: { 'x-project-id': 'proj-header' } }, + }; + await (envDispatcher as any).resolveEnvironmentContext( + context, + '/api/v1/projects/proj-unknown/data/task', + ); + + expect(envRegistry.resolveById).toHaveBeenNthCalledWith(1, 'proj-unknown'); + expect(envRegistry.resolveById).toHaveBeenNthCalledWith(2, 'proj-header'); + expect(context.projectId).toBe('proj-header'); + }); + }); }); \ No newline at end of file diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 3ec2faa7c..1ad8fa9e1 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -163,20 +163,43 @@ export class HttpDispatcher { throw { statusCode: 400, message: `Unknown data action: ${action}` }; } + /** + * Parse a project UUID out of a scoped URL path such as + * `/api/v1/projects/abc-123/data/task` or `/projects/abc-123/meta`. + * Returns `undefined` when the path does not match the scoped pattern. + */ + private extractProjectIdFromPath(path: string): string | undefined { + if (!path) return undefined; + const m = path.match(/\/projects\/([^/?#]+)/); + if (!m) return undefined; + const candidate = m[1]; + // Guard against matching control-plane routes like /cloud/projects. + // `/projects/` directly nested under the API prefix wins; + // `/cloud/projects/` is a CRUD endpoint on the control plane. + if (path.includes('/cloud/projects/')) return undefined; + return candidate; + } + /** * Resolve environment context for incoming request. * * Precedence: + * 0. URL path matches `/projects/:projectId/...` OR request.params.projectId set by router + * → envRegistry.resolveById(id) * 1. request.headers.host → envRegistry.resolveByHostname(host) * 2. request.headers['x-project-id'] → envRegistry.resolveById(id) * 3. session.activeEnvironmentId → envRegistry.resolveById(id) * 4. session.activeOrganizationId → find default project → envRegistry.resolveById(id) * - * Skip for paths: /auth, /cloud, /health, /discovery, /meta + * Skip for paths: /auth, /cloud, /health, /discovery (NOT /meta when scoped, + * so project-scoped meta routes can resolve their project). */ private async resolveEnvironmentContext(context: HttpProtocolContext, path: string): Promise { - // Skip environment resolution for control-plane routes - const skipPaths = ['/auth', '/cloud', '/health', '/discovery', '/meta']; + // Skip environment resolution for control-plane routes. + // NOTE: /meta is intentionally not in this list anymore — a scoped + // /projects/:id/meta path still needs the project resolved so the + // protocol can scope its answer. + const skipPaths = ['/auth', '/cloud', '/health', '/discovery']; if (skipPaths.some(p => path.startsWith(p))) { return; } @@ -187,6 +210,18 @@ export class HttpDispatcher { } try { + // 0. Try URL-param / path-embedded projectId (highest precedence). + const urlProjectId = this.extractProjectIdFromPath(path) + ?? context.request?.params?.projectId; + if (urlProjectId) { + const driver = await this.envRegistry.resolveById(urlProjectId); + if (driver) { + context.projectId = urlProjectId; + context.dataDriver = driver; + return; + } + } + // 1. Try hostname resolution const host = context.request?.headers?.host || context.request?.headers?.['Host']; if (host) { diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index ec03fc640..8b6fbddba 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -13,6 +13,8 @@ export { AppPlugin } from './app-plugin.js'; export { SeedLoaderService } from './seed-loader.js'; export { createDispatcherPlugin } from './dispatcher-plugin.js'; export type { DispatcherPluginConfig } from './dispatcher-plugin.js'; +export { createSystemProjectPlugin, SYSTEM_PROJECT_ID } from './system-project-plugin.js'; +export type { SystemProjectPluginConfig } from './system-project-plugin.js'; // Export HTTP Server Components export { HttpServer } from './http-server.js'; diff --git a/packages/runtime/src/system-project-plugin.test.ts b/packages/runtime/src/system-project-plugin.test.ts new file mode 100644 index 000000000..7d251e307 --- /dev/null +++ b/packages/runtime/src/system-project-plugin.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { createSystemProjectPlugin, SYSTEM_PROJECT_ID } from './system-project-plugin.js'; + +function makeCtx(services: Record = {}) { + return { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (services[name]) return services[name]; + throw new Error(`Service '${name}' not found`); + }), + getServices: vi.fn(() => new Map(Object.entries(services))), + hook: vi.fn(), + trigger: vi.fn().mockResolvedValue(undefined), + getKernel: vi.fn(), + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }; +} + +describe('createSystemProjectPlugin', () => { + it('returns a plugin with name and version', () => { + const plugin = createSystemProjectPlugin(); + expect(plugin.name).toBe('com.objectstack.runtime.system-project'); + expect(plugin.version).toBe('1.0.0'); + }); + + it('no-ops when provisioning service is absent (default: strict=false)', async () => { + const plugin = createSystemProjectPlugin(); + const ctx = makeCtx({}); + await expect(plugin.start!(ctx as any)).resolves.toBeUndefined(); + expect(ctx.logger.debug).toHaveBeenCalledWith( + expect.stringContaining('unavailable'), + ); + }); + + it('throws when strict=true and provisioning service is absent', async () => { + const plugin = createSystemProjectPlugin({ strict: true }); + const ctx = makeCtx({}); + await expect(plugin.start!(ctx as any)).rejects.toThrow(/cannot bootstrap system project/); + }); + + it('invokes provisionSystemProject and logs the returned id', async () => { + const provisionSystemProject = vi.fn().mockResolvedValue({ + project: { id: SYSTEM_PROJECT_ID, isSystem: true }, + warnings: [], + }); + const ctx = makeCtx({ + 'tenant.provisioning': { provisionSystemProject }, + }); + const plugin = createSystemProjectPlugin(); + await plugin.start!(ctx as any); + + expect(provisionSystemProject).toHaveBeenCalledOnce(); + expect(ctx.logger.info).toHaveBeenCalledWith( + expect.stringContaining('System project ready'), + expect.objectContaining({ projectId: SYSTEM_PROJECT_ID, isSystem: true }), + ); + }); + + it('resolves an alternate service name when configured', async () => { + const provisionSystemProject = vi.fn().mockResolvedValue({ + project: { id: SYSTEM_PROJECT_ID }, + }); + const ctx = makeCtx({ + 'custom.provisioning': { provisionSystemProject }, + }); + + const plugin = createSystemProjectPlugin({ serviceName: 'custom.provisioning' }); + await plugin.start!(ctx as any); + expect(provisionSystemProject).toHaveBeenCalled(); + }); + + it('swallows provisioning errors when strict=false', async () => { + const provisionSystemProject = vi.fn().mockRejectedValue(new Error('control plane down')); + const ctx = makeCtx({ + 'tenant.provisioning': { provisionSystemProject }, + }); + const plugin = createSystemProjectPlugin(); + await expect(plugin.start!(ctx as any)).resolves.toBeUndefined(); + expect(ctx.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to provision system project'), + expect.objectContaining({ error: 'control plane down' }), + ); + }); + + it('re-throws provisioning errors when strict=true', async () => { + const provisionSystemProject = vi.fn().mockRejectedValue(new Error('boom')); + const ctx = makeCtx({ + 'tenant.provisioning': { provisionSystemProject }, + }); + const plugin = createSystemProjectPlugin({ strict: true }); + await expect(plugin.start!(ctx as any)).rejects.toThrow('boom'); + }); +}); diff --git a/packages/runtime/src/system-project-plugin.ts b/packages/runtime/src/system-project-plugin.ts new file mode 100644 index 000000000..c97c20714 --- /dev/null +++ b/packages/runtime/src/system-project-plugin.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { Plugin, PluginContext } from '@objectstack/core'; + +/** + * The well-known UUID for the built-in system project. + * Kept in lockstep with `ProjectProvisioningService.provisionSystemProject`. + */ +export const SYSTEM_PROJECT_ID = '00000000-0000-0000-0000-000000000001'; + +/** + * Minimal surface of `ProjectProvisioningService` consumed by the plugin. + * Typed locally so the runtime package does not gain a hard dependency on + * `@objectstack/service-tenant` — the service is discovered at runtime via + * the kernel service registry. + */ +interface ProvisioningLike { + provisionSystemProject(): Promise<{ + project: { id: string; isSystem?: boolean }; + warnings?: string[]; + }>; +} + +export interface SystemProjectPluginConfig { + /** + * Service name that resolves to a `ProjectProvisioningService`-shaped + * object. Defaults to `tenant.provisioning` (convention used by + * `@objectstack/service-tenant`). + */ + serviceName?: string; + + /** + * When true, plugin treats a missing provisioning service as an error. + * Defaults to false — bootstrap is opt-in and must no-op gracefully when + * the tenant package is not part of the stack. + */ + strict?: boolean; +} + +/** + * System Project Bootstrap Plugin + * + * Ensures the built-in system project (well-known UUID + * {@link SYSTEM_PROJECT_ID}) exists on the control plane the first time the + * runtime starts. Calls are idempotent — `provisionSystemProject()` returns + * the existing row when the project has already been created. + * + * Register AFTER the tenant service is available so the provisioning service + * can be resolved from the kernel. + * + * @example + * ```ts + * kernel.use(tenantPlugin); + * kernel.use(createSystemProjectPlugin()); + * ``` + */ +export function createSystemProjectPlugin(config: SystemProjectPluginConfig = {}): Plugin { + const serviceName = config.serviceName ?? 'tenant.provisioning'; + + return { + name: 'com.objectstack.runtime.system-project', + version: '1.0.0', + + init: async (_ctx: PluginContext) => { + // Consumer-only plugin; nothing to register at init-time. + }, + + start: async (ctx: PluginContext) => { + let service: ProvisioningLike | undefined; + try { + service = ctx.getService(serviceName); + } catch { + // Service registry throws when the key is not found. + service = undefined; + } + + if (!service || typeof service.provisionSystemProject !== 'function') { + if (config.strict) { + throw new Error( + `[SystemProjectPlugin] Provisioning service '${serviceName}' not found — cannot bootstrap system project.`, + ); + } + ctx.logger.debug( + `[SystemProjectPlugin] Provisioning service '${serviceName}' unavailable — system project bootstrap skipped.`, + ); + return; + } + + try { + const result = await service.provisionSystemProject(); + const warnings = result.warnings ?? []; + ctx.logger.info('[SystemProjectPlugin] System project ready', { + projectId: result.project.id, + isSystem: result.project.isSystem, + warnings, + }); + } catch (err: any) { + if (config.strict) throw err; + ctx.logger.warn('[SystemProjectPlugin] Failed to provision system project', { + error: err?.message ?? String(err), + }); + } + }, + }; +} diff --git a/packages/services/service-tenant/src/objects/sys-project.object.ts b/packages/services/service-tenant/src/objects/sys-project.object.ts index c1c4f65d6..a7e9979e3 100644 --- a/packages/services/service-tenant/src/objects/sys-project.object.ts +++ b/packages/services/service-tenant/src/objects/sys-project.object.ts @@ -90,6 +90,13 @@ export const SysProject = ObjectSchema.create({ description: 'Whether this is the default project for the organization. Exactly one per org.', }), + is_system: Field.boolean({ + label: 'Is System', + required: true, + defaultValue: false, + description: 'Whether this is a system project (platform infrastructure, not user data).', + }), + region: Field.text({ label: 'Region', required: false, diff --git a/packages/services/service-tenant/src/project-provisioning.test.ts b/packages/services/service-tenant/src/project-provisioning.test.ts new file mode 100644 index 000000000..9a80b5cf1 --- /dev/null +++ b/packages/services/service-tenant/src/project-provisioning.test.ts @@ -0,0 +1,240 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { + ProjectProvisioningService, + MockProjectDatabaseAdapter, + NoopSecretEncryptor, + type ProjectDatabaseAdapter, +} from './project-provisioning.js'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const SYSTEM_PROJECT_ID = '00000000-0000-0000-0000-000000000001'; +const PLATFORM_ORG_ID = '00000000-0000-0000-0000-000000000000'; + +describe('ProjectProvisioningService.provisionProject', () => { + it('returns a fully-formed project + credential in detached mode', async () => { + const svc = new ProjectProvisioningService({ + defaultRegion: 'eu-west-1', + defaultStorageLimitMb: 2048, + }); + + const result = await svc.provisionProject({ + organizationId: 'org-123', + slug: 'dev', + projectType: 'development', + createdBy: 'user-1', + }); + + expect(result.project.id).toMatch(UUID_RE); + expect(result.project.organizationId).toBe('org-123'); + expect(result.project.slug).toBe('dev'); + expect(result.project.projectType).toBe('development'); + expect(result.project.region).toBe('eu-west-1'); + expect(result.project.status).toBe('active'); + expect(result.project.isDefault).toBe(false); + expect(result.project.isSystem).toBe(false); + + // Database addressing is on the project row + expect(result.project.storageLimitMb).toBe(2048); + expect(result.project.databaseDriver).toBe('turso'); + expect(result.project.databaseUrl).toContain('libsql://'); + + expect(result.credential.projectId).toBe(result.project.id); + expect(result.credential.status).toBe('active'); + expect(result.credential.authorization).toBe('full_access'); + expect(result.credential.encryptionKeyId).toBe('noop'); + + expect(result.durationMs).toBeGreaterThanOrEqual(0); + // Detached mode must warn that control plane was not written. + expect(result.warnings?.some((w) => w.includes('Control-plane driver'))).toBe(true); + }); + + it('persists control-plane rows when a driver is configured', async () => { + const created: Array<{ object: string; data: Record }> = []; + const driver = { + create: vi.fn(async (object: string, data: Record) => { + created.push({ object, data }); + return data; + }), + find: vi.fn(async () => []), + findOne: vi.fn(async () => null), + update: vi.fn(async () => ({})), + }; + + const svc = new ProjectProvisioningService({ + controlPlaneDriver: driver as any, + adapters: [new MockProjectDatabaseAdapter('turso')], + }); + + const result = await svc.provisionProject({ + organizationId: 'org-42', + slug: 'prod', + projectType: 'production', + isDefault: true, + createdBy: 'user-1', + }); + + const objects = created.map((c) => c.object); + expect(objects).toEqual(['project', 'project_credential']); + + const projectRow = created.find((c) => c.object === 'project')!.data; + expect(projectRow.organization_id).toBe('org-42'); + expect(projectRow.is_default).toBe(true); + expect(projectRow.is_system).toBe(false); + expect(projectRow.slug).toBe('prod'); + expect(projectRow.database_url).toBeTruthy(); + expect(projectRow.database_driver).toBe('turso'); + + expect(result.warnings).toBeUndefined(); + }); + + it('rejects a second default project for the same org', async () => { + const driver = { + find: vi.fn(async () => [{ id: 'existing-project-id' }]), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + }; + + const svc = new ProjectProvisioningService({ + controlPlaneDriver: driver as any, + }); + + await expect( + svc.provisionProject({ + organizationId: 'org-42', + slug: 'prod-2', + projectType: 'production', + isDefault: true, + createdBy: 'user-1', + }), + ).rejects.toThrow(/already has a default project/); + + expect(driver.create).not.toHaveBeenCalled(); + }); +}); + +describe('ProjectProvisioningService.provisionSystemProject', () => { + it('creates system project with well-known UUID in detached mode', async () => { + const svc = new ProjectProvisioningService({ + defaultRegion: 'us-east-1', + }); + + const result = await svc.provisionSystemProject(); + + expect(result.project.id).toBe(SYSTEM_PROJECT_ID); + expect(result.project.organizationId).toBe(PLATFORM_ORG_ID); + expect(result.project.slug).toBe('system'); + expect(result.project.displayName).toBe('System'); + expect(result.project.projectType).toBe('production'); + expect(result.project.isDefault).toBe(false); + expect(result.project.isSystem).toBe(true); + expect(result.project.plan).toBe('enterprise'); + expect(result.project.status).toBe('active'); + expect(result.project.createdBy).toBe('system'); + expect(result.project.hostname).toBe('system.objectstack.internal'); + + // System project uses control plane DB - no separate physical DB + expect(result.project.databaseUrl).toBeUndefined(); + expect(result.project.databaseDriver).toBeUndefined(); + expect(result.project.storageLimitMb).toBeUndefined(); + + expect(result.credential.projectId).toBe(SYSTEM_PROJECT_ID); + expect(result.credential.secretCiphertext).toBe(''); + + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('persists system project to control plane when driver is configured', async () => { + const created: Array<{ object: string; data: Record }> = []; + const driver = { + create: vi.fn(async (object: string, data: Record) => { + created.push({ object, data }); + return data; + }), + find: vi.fn(async () => []), + findOne: vi.fn(async () => null), + update: vi.fn(async () => ({})), + }; + + const svc = new ProjectProvisioningService({ + controlPlaneDriver: driver as any, + }); + + const result = await svc.provisionSystemProject(); + + expect(created).toHaveLength(1); + const projectRow = created[0].data; + + expect(created[0].object).toBe('project'); + expect(projectRow.id).toBe(SYSTEM_PROJECT_ID); + expect(projectRow.organization_id).toBe(PLATFORM_ORG_ID); + expect(projectRow.slug).toBe('system'); + expect(projectRow.is_system).toBe(true); + expect(projectRow.is_default).toBe(false); + expect(projectRow.plan).toBe('enterprise'); + expect(projectRow.database_url).toBeUndefined(); + + expect(result.warnings?.some((w) => w.includes('created successfully'))).toBe(true); + }); + + it('returns existing system project if already created (idempotent)', async () => { + const findOneCalled: string[] = []; + const driver = { + create: vi.fn(async () => ({})), + find: vi.fn(async () => []), + findOne: vi.fn(async (object: string, query: any) => { + findOneCalled.push(object); + if (object === 'project' && query.where?.id === SYSTEM_PROJECT_ID) { + return { + id: SYSTEM_PROJECT_ID, + organization_id: PLATFORM_ORG_ID, + slug: 'system', + display_name: 'System', + project_type: 'production', + is_default: false, + is_system: true, + region: 'us-east-1', + plan: 'enterprise', + status: 'active', + created_by: 'system', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + provisioned_at: new Date().toISOString(), + hostname: 'system.objectstack.internal', + }; + } + return null; + }), + update: vi.fn(async () => ({})), + }; + + const svc = new ProjectProvisioningService({ + controlPlaneDriver: driver as any, + }); + + const result = await svc.provisionSystemProject(); + + // Should have queried for existing system project + expect(findOneCalled).toContain('project'); + + // Should NOT have called create since project exists + expect(driver.create).not.toHaveBeenCalled(); + + // Should return the existing project + expect(result.project.id).toBe(SYSTEM_PROJECT_ID); + expect(result.project.isSystem).toBe(true); + expect(result.warnings).toContain('System project already exists'); + }); + + it('metadata field contains expected system project metadata', async () => { + const svc = new ProjectProvisioningService(); + + const result = await svc.provisionSystemProject(); + + expect(result.project.metadata).toBeDefined(); + expect(result.project.metadata?.description).toBe('Built-in system project for platform infrastructure'); + expect(result.project.metadata?.protected).toBe(true); + }); +}); diff --git a/packages/services/service-tenant/src/project-provisioning.ts b/packages/services/service-tenant/src/project-provisioning.ts index e7e7edf23..6a5cc111a 100644 --- a/packages/services/service-tenant/src/project-provisioning.ts +++ b/packages/services/service-tenant/src/project-provisioning.ts @@ -352,6 +352,7 @@ export class ProjectProvisioningService { displayName: parsed.displayName ?? parsed.slug, projectType: parsed.projectType, isDefault: parsed.isDefault ?? false, + isSystem: false, // Regular projects are not system projects region, plan: parsed.plan ?? 'free', status: 'active', @@ -386,6 +387,7 @@ export class ProjectProvisioningService { display_name: project.displayName, project_type: project.projectType, is_default: project.isDefault, + is_system: project.isSystem, region: project.region, plan: project.plan, status: project.status, @@ -480,6 +482,145 @@ export class ProjectProvisioningService { return credential; } + /** + * Provision the built-in system project during platform bootstrap. + * This project contains system-level packages and operates on the control plane DB. + * + * @returns The provisioned system project or existing if already created (idempotent) + */ + async provisionSystemProject(): Promise { + const SYSTEM_PROJECT_ID = '00000000-0000-0000-0000-000000000001'; + const PLATFORM_ORG_ID = '00000000-0000-0000-0000-000000000000'; + const startedAt = Date.now(); + const warnings: string[] = []; + + // Check if system project already exists (idempotent operation) + if (this.config.controlPlaneDriver) { + try { + const existing = await this.config.controlPlaneDriver.findOne('project', { + where: { id: SYSTEM_PROJECT_ID }, + }); + + if (existing) { + // System project already exists - return it + const credentialId = randomUUID(); + const nowIso = new Date().toISOString(); + + return { + project: { + id: existing.id, + organizationId: existing.organization_id, + slug: existing.slug, + displayName: existing.display_name, + projectType: existing.project_type, + isDefault: existing.is_default, + isSystem: existing.is_system ?? true, + region: existing.region, + plan: existing.plan, + status: existing.status, + createdBy: existing.created_by, + createdAt: existing.created_at, + updatedAt: existing.updated_at, + databaseUrl: existing.database_url, + databaseDriver: existing.database_driver, + storageLimitMb: existing.storage_limit_mb, + provisionedAt: existing.provisioned_at, + metadata: existing.metadata ? JSON.parse(existing.metadata) : undefined, + hostname: existing.hostname, + }, + credential: { + id: credentialId, + projectId: SYSTEM_PROJECT_ID, + secretCiphertext: '', + encryptionKeyId: this.encryptor.keyId, + authorization: 'full_access', + status: 'active', + createdAt: nowIso, + }, + durationMs: Date.now() - startedAt, + warnings: ['System project already exists'], + }; + } + } catch (error) { + // Project not found - proceed with creation + } + } + + // Create new system project + const nowIso = new Date().toISOString(); + const credentialId = randomUUID(); + + const project: Project = { + id: SYSTEM_PROJECT_ID, + organizationId: PLATFORM_ORG_ID, + slug: 'system', + displayName: 'System', + projectType: 'production', + isDefault: false, + isSystem: true, + region: this.config.defaultRegion, + plan: 'enterprise', + status: 'active', + createdBy: 'system', + createdAt: nowIso, + updatedAt: nowIso, + metadata: { + description: 'Built-in system project for platform infrastructure', + protected: true, + }, + databaseUrl: undefined, + databaseDriver: undefined, + storageLimitMb: undefined, + provisionedAt: nowIso, + hostname: 'system.objectstack.internal', + }; + + const credential: ProjectCredential = { + id: credentialId, + projectId: SYSTEM_PROJECT_ID, + secretCiphertext: '', + encryptionKeyId: this.encryptor.keyId, + authorization: 'full_access', + status: 'active', + createdAt: nowIso, + }; + + // Persist to control plane + if (this.config.controlPlaneDriver) { + try { + await this.config.controlPlaneDriver.create('project', { + id: project.id, + organization_id: project.organizationId, + slug: project.slug, + display_name: project.displayName, + project_type: project.projectType, + is_default: project.isDefault, + is_system: project.isSystem, + region: project.region, + plan: project.plan, + status: project.status, + created_by: project.createdBy, + created_at: project.createdAt, + updated_at: project.updatedAt, + database_url: project.databaseUrl, + database_driver: project.databaseDriver, + storage_limit_mb: project.storageLimitMb, + provisioned_at: project.provisionedAt, + metadata: project.metadata ? JSON.stringify(project.metadata) : null, + hostname: project.hostname, + }); + + warnings.push('System project created successfully'); + } catch (error) { + throw new Error( + `Failed to persist system project: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return { project, credential, durationMs: Date.now() - startedAt, warnings }; + } + registerAdapter(adapter: ProjectDatabaseAdapter): void { this.adapters.set(adapter.driver, adapter); } diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index 58a1d899b..3d7dfb790 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -80,10 +80,27 @@ export const RestApiConfigSchema = z.object({ enableBatch: z.boolean().default(true).describe('Enable batch operation endpoints'), /** - * Enable discovery endpoint + * Enable API discovery endpoint */ enableDiscovery: z.boolean().default(true).describe('Enable API discovery endpoint'), + /** + * Enable project-scoped routing (/api/v1/projects/:projectId/data/...) + * When true, all data/meta/AI APIs are scoped under /projects/:projectId + * Control plane routes (/auth, /cloud) remain unscoped + */ + enableProjectScoping: z.boolean().default(false) + .describe('Enable project-scoped routing for data/meta/AI APIs'), + + /** + * Project ID resolution strategy when enableProjectScoping is true + * - 'required': projectId must be in URL (strict, recommended for production) + * - 'optional': projectId can be in URL or fallback to headers/session + * - 'auto': backward compatible - accepts both scoped and unscoped routes + */ + projectResolution: z.enum(['required', 'optional', 'auto']).default('auto') + .describe('Project ID resolution strategy'), + /** * API documentation configuration */ diff --git a/packages/spec/src/cloud/project.zod.ts b/packages/spec/src/cloud/project.zod.ts index 2eb63b053..3399063f6 100644 --- a/packages/spec/src/cloud/project.zod.ts +++ b/packages/spec/src/cloud/project.zod.ts @@ -83,6 +83,9 @@ export const ProjectSchema = z.object({ /** Whether this is the organization's **default** project. Exactly one per org. */ isDefault: z.boolean().default(false).describe('Whether this is the default project for the organization'), + /** Whether this is a system project (platform infrastructure, not user data). */ + isSystem: z.boolean().default(false).describe('Whether this is a system project (platform infrastructure, not user data)'), + /** Region where the physical database is deployed. */ region: z.string().optional().describe('Region where the physical database is deployed (e.g. us-east-1)'),