Skip to content

Commit b13b8fb

Browse files
GiniGini
authored andcommitted
Add local organization membership scaffold
1 parent bdb38e6 commit b13b8fb

13 files changed

Lines changed: 170 additions & 14 deletions

File tree

HANDOVER.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# ONEVibe — Agent Handover Document
22

33
> **Date**: 2026-07-17
4-
> **Status**: Local phases 1–3 are implemented and the professional UI has crossed the Zustand and ordinary-collection TanStack Query boundaries. Governed MCP declarations, bounded MCP health probing, a tested opt-in MCP capability facade, truthful demo skill status, a GitHub-catalog skill marketplace boundary, feature-gated Better Auth, authenticated owner scoping, and a reviewed Drizzle/Postgres import proof are present. LiteLLM-only enforcement remains mandatory. The running application is still SQLite-backed; protected Claude/provider acceptance, production auth, the Postgres repository/runtime switch, deployment, cloud sandbox attestation, MCP secret brokering, and external MCP health/attestation remain open.
4+
> **Status**: Local phases 1–3 are implemented and the professional UI has crossed the Zustand and ordinary-collection TanStack Query boundaries. Governed MCP declarations, bounded MCP health probing, a tested opt-in MCP capability facade, truthful demo skill status, a GitHub-catalog skill marketplace boundary, feature-gated Better Auth, authenticated owner scoping, a local organization-membership scaffold, and a reviewed Drizzle/Postgres import proof are present. LiteLLM-only enforcement remains mandatory for every harness and every environment: data sovereignty, centralized routing, cost control, and model optimization are product requirements. The running application is still SQLite-backed; protected Claude/provider acceptance, production auth, the Postgres repository/runtime switch, deployment, cloud sandbox attestation, MCP secret brokering, and external MCP health/attestation remain open.
55
> **For**: The next agent (or human) picking this up cold.
66
> **Read this entire document before touching any code.**
77
@@ -39,7 +39,7 @@ The abstraction that enforces this: `server/runtime-adapter.ts` — the `Runtime
3939
| Claude SDK adapter | `server/claude-sdk-runner.ts` (422 lines) | Real — wraps `@anthropic-ai/claude-agent-sdk` and fails closed without LiteLLM; opt-in MCP facade is local-only |
4040
| ONEComputer adapter | `server/onecomputer-sandbox-runner.ts` (845 lines) | Real — wraps the development ONEComputer cloud sandbox; production microVM attestation remains open |
4141
| Demo adapter | `server/demo-runner.ts` (172 lines) | Fake — scripted responses, zero model calls |
42-
| Task store | `server/store.ts` + `server/persistence/` | Real — local SQLite via `better-sqlite3`; Postgres/Drizzle schema, owner-aware importer, and disposable migration/restart proof exist, but the running repository adapter is still open |
42+
| Task store | `server/store.ts` + `server/persistence/` | Real — local SQLite via `better-sqlite3`; Postgres/Drizzle schema, owner-aware importer, disposable migration/restart proof, and local org/member records exist, but the running repository adapter and org-backed data scope are still open |
4343
| SSE streaming | `server/task-event-stream.ts` | Real |
4444
| Approval service | `server/wallet-approval-service.ts` | Real — wallet-gated approvals |
4545
| UI — cosmetic | `src/index.css`, `src/components/*` | Done — Claude-calibrated light mode, Inter font, cream palette |
@@ -53,7 +53,8 @@ The abstraction that enforces this: `server/runtime-adapter.ts` — the `Runtime
5353
3. **The running app is not Postgres-backed** — local user scoping is proven, and the Drizzle schema/import/restart proof exists; the TaskStore repository adapter, idempotent application-level proof, and `DATABASE_URL` runtime switch remain Phase 4 work
5454
4. **No managed deploy path** — a non-root Docker image and local Compose smoke path now exist, but Railway/Fly configuration, secrets, auth, and production operations remain open
5555
5. **No production sandbox attestation** — local host and development-provider paths must not be described as microVM isolation or default-deny egress
56-
6. **The active task remains an intentional state boundary** — durable SSE replay and the active snapshot are still owned by `useTask`; active-task mutations use Query mutation lifecycle/pending state and reconcile server-derived caches without creating a second client authority
56+
6. **Organization membership is not yet a data-plane grant** — local authenticated owners can create organizations and owners can add/remove members, but task/project/runtime access remains owner-scoped until the Postgres-backed organization policy and repository switch are accepted
57+
7. **The active task remains an intentional state boundary** — durable SSE replay and the active snapshot are still owned by `useTask`; active-task mutations use Query mutation lifecycle/pending state and reconcile server-derived caches without creating a second client authority
5758
7. **Remaining extension/release gaps** — protected provider-backed marketplace materialization, production MCP secret brokering/external health attestation, dependency advisory resolution, and browser evidence remain open in `TODO.md`
5859

5960
### How to run it locally

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ Reference: `plan/04-cloud-infrastructure.md`
6969
- [ ] **P4-03** Containerise — current non-root multi-stage `Dockerfile`, hardened SQLite-volume `docker-compose.yml`, `.env.example`, and a GitHub Actions build/non-root/read-only smoke gate are implemented; keep open until the P4-02 Postgres contract is wired into the image/Compose path rather than shipping an unused database service
7070
- [ ] **P4-04** Deploy to Railway or Fly.io — `railway.toml` or `fly.toml`; deploy instructions in `plan/04-cloud-infrastructure.md#deploy`
7171
- [ ] **P4-05** Add cloud sandbox — integrate **e2b.dev** (`@e2b/code-interpreter`) as the default `sandboxed` execution backend; surface sandbox preview URL in workspace iframe; `E2bRuntimeAdapter` wraps e2b and implements the full `RuntimeAdapter` interface
72-
- [ ] **P4-06** Add multi-tenancy scaffolding — local user ownership now scopes tasks, projects, schedules, conversations, MCP declarations, and task routes; the authenticated two-user HTTP harness now covers cross-user task/project/file/schedule/MCP reads and mutations; keep open until `orgs`/`org_members`, Postgres ownership, migration/import, and exhaustive HTTP negative coverage for every route are complete
72+
- [ ] **P4-06** Add multi-tenancy scaffolding — local user ownership now scopes tasks, projects, schedules, conversations, MCP declarations, and task routes; migration v9 adds local `organizations`/`organization_members` records with owner/member HTTP routes and explicit owner-only mutations; the authenticated two-user HTTP harness proves membership visibility does not widen task access; keep open until the Postgres repository switch, org-backed policy/data authorization, migration/import, and exhaustive HTTP negative coverage for every route are complete
7373
- [ ] **P4-07** Resolve dependency audit gate — `npm audit --omit=dev` reports five moderate Better Auth/Drizzle Kit/esbuild advisories; investigate a non-breaking upgrade or formally document risk acceptance before production deployment
7474

7575
---

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ The proposed auth/database contract is recorded in [`AUTH-POSTGRES-ADR.md`](AUTH
6363

6464
## Execution-path diagnostics
6565

66-
`GET /api/diagnostics` is an authenticated, bounded status contract for the Computers view. It reports whether the server-controlled LiteLLM boundary is configured, whether the current request is session-scoped, which local persistence driver is active, provider readiness, the configured sandbox boundary, and the count of secret-free MCP declarations. `GET /api/mcp/:id/health` independently probes one owner-scoped declaration through initialization and `tools/list`, returning only bounded status, latency, tool count, and generic failure detail. Neither route returns credentials, prompts, raw provider payloads, or production attestation claims. The Postgres contract and cloud/microVM boundaries remain explicit follow-up work until their runtime proofs exist.
66+
`GET /api/diagnostics` is an authenticated, bounded status contract for the Computers view. It reports whether the server-controlled LiteLLM boundary is configured, whether the current request is session-scoped, which local persistence driver is active, provider readiness, the configured sandbox boundary, and the count of secret-free MCP declarations. `GET /api/mcp/:id/health` independently probes one owner-scoped declaration through initialization and `tools/list`, returning only bounded status, latency, tool count, and generic failure detail. Neither route returns credentials, prompts, raw provider payloads, or production attestation claims. Local organization membership is a separate identity scaffold: it provides owner/member records and owner-only membership mutations, but it does not grant access to tasks, projects, runtimes, or MCP data. The Postgres contract and cloud/microVM boundaries remain explicit follow-up work until their runtime proofs exist.
6767

6868
Skill selection is also provider-owned: Claude-backed adapters materialize the selected, hashed packs in the task workspace; the deterministic demo records selection as `not_executed_demo` and never writes skill files. The UI and event ledger must not collapse these states into a generic "skill applied" label.
6969

docs/IMPLEMENTATION-LOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,3 +790,9 @@ baseline harness in CI.
790790
- Added `GET /api/skills`, `POST /api/skills/install`, and `DELETE /api/skills/:id`. Marketplace entries are not selectable until installed; removal is rejected while a pending/running/waiting task depends on the skill. Provider adapters resolve installed content at materialization time; demo mode records `not_executed_demo` and writes no skill files.
791791
- Added Skills Library install/remove controls, the first `meeting-brief` catalog/content pair, focused marketplace/store tests, and `npm run e2e:skill-marketplace`. The E2E uses a loopback GitHub-shaped fixture and proves install, task selection, truthful demo evidence, and removal; it does not claim external GitHub reachability or protected Claude execution.
792792
- Verification: `npm run check` passed with 49 test files / 245 tests, `npm run db:check` passed, and the marketplace E2E passed. The protected provider materialization gate remains open.
793+
## 2026-07-17 — local organization membership scaffold and mandatory LiteLLM wording
794+
795+
- Added migration v9 for local `organizations` and `organization_members` records, with owner/member role checks, authenticated HTTP list/create/member routes, owner-only add/remove operations, and a no-self-removal guard. The browser/API layer exposes only typed organization records; membership is not used as a task/project/runtime authorization grant.
796+
- Expanded the Better Auth owner-scope acceptance harness to create an organization, verify an unlisted organization before membership, add a second authenticated user, verify member visibility, reject member-admin mutations, reject owner self-removal, and prove the member still cannot see the owner's task inventory.
797+
- Re-stated in the handover that every model request and agentic turn must traverse the server-controlled LiteLLM relay for data sovereignty, routing, cost control, and optimization. Direct first-party Anthropic traffic remains prohibited in local, test, emergency, and release paths; the Claude SDK is only a harness using Anthropic-compatible transport variables pointed at LiteLLM.
798+
- Verification: `npm run check`, `npm run db:check`, and `npm run e2e:auth-owner` passed. This is local SQLite/auth evidence only; Postgres-backed org authorization, production delivery, provider acceptance, and sandbox isolation remain open.

scripts/auth-owner-e2e.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
* reads that delivery to complete the real sign-in flow. No product route
77
* accepts an OTP from the browser or exposes a development bypass.
88
*
9-
* This proves SQLite/local HTTP ownership boundaries. It does not prove
10-
* production email delivery, Postgres ownership, organization membership, or
11-
* provider/sandbox isolation.
9+
* This proves SQLite/local HTTP ownership boundaries and the local organization
10+
* membership scaffold. It does not prove production email delivery, Postgres
11+
* ownership, organization-backed data authorization, or provider/sandbox isolation.
1212
*/
1313
import assert from 'node:assert/strict'
1414
import { createServer } from 'node:http'
@@ -145,6 +145,8 @@ const main = async () => {
145145
assert.equal(unauthorized.body.code, 'unauthorized')
146146

147147
const ownerA = await signIn(baseUrl, 'owner-a@example.test', mail.delivered)
148+
const organization = await request<{ id: string }>(baseUrl, '/api/organizations', { method: 'POST', body: JSON.stringify({ name: 'Owner A organization' }) }, ownerA.cookie)
149+
assert.equal(organization.response.status, 201, JSON.stringify(organization.body))
148150
const projectA = await request<{ id: string }>(baseUrl, '/api/projects', { method: 'POST', body: JSON.stringify({ name: 'Owner A workspace', context: 'Private A context' }) }, ownerA.cookie)
149151
assert.equal(projectA.response.status, 201, JSON.stringify(projectA.body))
150152
const projectFile = await request<{ files: Array<{ path: string }> }>(baseUrl, `/api/projects/${projectA.body.id}/files`, { method: 'POST', body: JSON.stringify({ name: 'private-notes.md', mimeType: 'text/markdown', dataBase64: Buffer.from('owner A only').toString('base64') }) }, ownerA.cookie)
@@ -165,6 +167,26 @@ const main = async () => {
165167
assert.equal(created.response.status, 201, JSON.stringify(created.body))
166168

167169
const ownerB = await signIn(baseUrl, 'owner-b@example.test', mail.delivered)
170+
const ownerBOrganizationsBeforeMembership = await request<{ organizations: Array<{ id: string }> }>(baseUrl, '/api/organizations', {}, ownerB.cookie)
171+
assert.equal(ownerBOrganizationsBeforeMembership.response.status, 200)
172+
assert.deepEqual(ownerBOrganizationsBeforeMembership.body.organizations, [])
173+
const addedMember = await request<{ organizationId: string; userId: string; role: string }>(baseUrl, `/api/organizations/${organization.body.id}/members`, { method: 'POST', body: JSON.stringify({ userId: ownerB.userId }) }, ownerA.cookie)
174+
assert.equal(addedMember.response.status, 201, JSON.stringify(addedMember.body))
175+
assert.equal(addedMember.body.role, 'member')
176+
const ownerBOrganizations = await request<{ organizations: Array<{ id: string }> }>(baseUrl, '/api/organizations', {}, ownerB.cookie)
177+
assert.equal(ownerBOrganizations.response.status, 200)
178+
assert.deepEqual(ownerBOrganizations.body.organizations.map((organization) => organization.id), [organization.body.id])
179+
const members = await request<{ members: Array<{ userId: string; role: string }> }>(baseUrl, `/api/organizations/${organization.body.id}/members`, {}, ownerB.cookie)
180+
assert.equal(members.response.status, 200)
181+
assert.deepEqual(members.body.members.map((member) => ({ userId: member.userId, role: member.role })), [
182+
{ userId: ownerA.userId, role: 'owner' }, { userId: ownerB.userId, role: 'member' },
183+
])
184+
const forbiddenOrganizationMutation = await request<{ error?: string }>(baseUrl, `/api/organizations/${organization.body.id}/members`, { method: 'POST', body: JSON.stringify({ userId: ownerA.userId }) }, ownerB.cookie)
185+
assert.equal(forbiddenOrganizationMutation.response.status, 403)
186+
assert.equal(forbiddenOrganizationMutation.body.error, 'Organization owner access required')
187+
const forbiddenOwnerRemoval = await request<{ error?: string }>(baseUrl, `/api/organizations/${organization.body.id}/members/${encodeURIComponent(ownerA.userId)}`, { method: 'DELETE' }, ownerA.cookie)
188+
assert.equal(forbiddenOwnerRemoval.response.status, 409)
189+
assert.equal(forbiddenOwnerRemoval.body.error, 'Organization owner cannot remove themselves')
168190
const ownerBProjects = await request<{ projects: Array<{ id: string }> }>(baseUrl, '/api/projects', {}, ownerB.cookie)
169191
assert.equal(ownerBProjects.response.status, 200)
170192
assert.ok(!ownerBProjects.body.projects.some((project) => project.id === projectA.body.id))
@@ -207,7 +229,7 @@ const main = async () => {
207229
assert.equal(ownerATask.body.id, created.body.id)
208230
assert.equal(ownerATask.body.ownerUserId, ownerA.userId)
209231

210-
console.log(JSON.stringify({ auth: 'better-auth email OTP through loopback delivery fixture', unauthorizedStatus: unauthorized.response.status, ownerA: ownerA.userId, ownerB: ownerB.userId, taskId: created.body.id, ownerBTaskCount: ownerBTasks.body.tasks.length, forbiddenStatuses: [forbiddenTask.response.status, forbiddenMove.response.status, forbiddenTags.response.status, forbiddenProjectUpdate.response.status, forbiddenProjectFile.response.status, forbiddenSchedule.response.status, forbiddenMcp.response.status, forbiddenMcpHealth.response.status], ownerReadStatus: ownerATask.response.status, productionLimitations: ['real email delivery', 'Postgres repository/runtime', 'organization membership'] }, null, 2))
232+
console.log(JSON.stringify({ auth: 'better-auth email OTP through loopback delivery fixture', unauthorizedStatus: unauthorized.response.status, ownerA: ownerA.userId, ownerB: ownerB.userId, organizationId: organization.body.id, taskId: created.body.id, ownerBTaskCount: ownerBTasks.body.tasks.length, forbiddenStatuses: [forbiddenTask.response.status, forbiddenMove.response.status, forbiddenTags.response.status, forbiddenProjectUpdate.response.status, forbiddenProjectFile.response.status, forbiddenSchedule.response.status, forbiddenMcp.response.status, forbiddenMcpHealth.response.status, forbiddenOrganizationMutation.response.status, forbiddenOwnerRemoval.response.status], ownerReadStatus: ownerATask.response.status, productionLimitations: ['real email delivery', 'Postgres repository/runtime', 'organization-backed data authorization', 'provider/sandbox isolation'] }, null, 2))
211233
} finally {
212234
await api.stop(); await rm(dataRoot, { recursive: true, force: true }); await new Promise<void>((resolve) => mail.server.close(() => resolve()))
213235
}

0 commit comments

Comments
 (0)