Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3231b91691 | ||
|
|
be32b693d0 | ||
|
|
49b7943420 | ||
|
|
19e16bd44f |
@@ -40,6 +40,11 @@ BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
||||
BETTER_AUTH_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
|
||||
# Public gateway URL — accessible from the browser, not just the server.
|
||||
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
||||
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
|
||||
+27
-14
@@ -32,6 +32,11 @@ variables:
|
||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||
# the separate `event: tag` entry.)
|
||||
# #1407: ONE shared anchor for all three image steps. A second main-only
|
||||
# anchor previously gated build-web/build-appservice, so next-lane pushes
|
||||
# published gateway sha images with no web/appservice counterpart — no
|
||||
# sha-parity set existed for next-lane containerized deploys. Every image
|
||||
# step now builds on next too (sha-only destinations, enforced per step).
|
||||
- &image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
@@ -44,16 +49,6 @@ variables:
|
||||
- '.woodpecker/**'
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
- &main_image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main, next]
|
||||
@@ -474,7 +469,7 @@ steps:
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
@@ -488,8 +483,17 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next appservice publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||
@@ -509,7 +513,7 @@ steps:
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
@@ -523,8 +527,17 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next web publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'reflect-metadata';
|
||||
import { type CanActivate, type ExecutionContext, type INestApplication } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { TeamsController } from './teams.controller.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
const teamAlpha = { id: 'team-alpha', name: 'Alpha' };
|
||||
const teamBeta = { id: 'team-beta', name: 'Beta' };
|
||||
|
||||
// user-1 is a member of team-alpha only; admin-1 has role admin.
|
||||
let currentUser: { id: string; role?: string } = { id: 'user-1' };
|
||||
|
||||
const teamsServiceMock = {
|
||||
findAll: vi.fn(() => Promise.resolve([teamAlpha, teamBeta])),
|
||||
findAllForUser: vi.fn((userId: string) =>
|
||||
Promise.resolve(userId === 'user-1' ? [teamAlpha] : []),
|
||||
),
|
||||
findById: vi.fn((id: string) => Promise.resolve([teamAlpha, teamBeta].find((t) => t.id === id))),
|
||||
listMembers: vi.fn(() => Promise.resolve([{ teamId: 'team-alpha', userId: 'user-1' }])),
|
||||
isMember: vi.fn((teamId: string, userId: string) =>
|
||||
Promise.resolve(teamId === 'team-alpha' && userId === 'user-1'),
|
||||
),
|
||||
};
|
||||
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requestContext = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: { id: string; role?: string } }>();
|
||||
requestContext.user = currentUser;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
describe('teams endpoints are scoped to membership', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [TeamsController],
|
||||
providers: [{ provide: TeamsService, useValue: teamsServiceMock }],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(authGuard)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
currentUser = { id: 'user-1' };
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns only the teams the user belongs to', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha]);
|
||||
expect(teamsServiceMock.findAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns every team for an admin', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha, teamBeta]);
|
||||
expect(teamsServiceMock.findAllForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 403 for a non-member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 404 for a missing team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-missing');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns the team for a member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(teamAlpha);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members returns 403 for a non-member and members for a member', async () => {
|
||||
const denied = await request(app.getHttpServer()).get('/api/teams/team-beta/members');
|
||||
expect(denied.status).toBe(403);
|
||||
expect(teamsServiceMock.listMembers).not.toHaveBeenCalled();
|
||||
|
||||
const allowed = await request(app.getHttpServer()).get('/api/teams/team-alpha/members');
|
||||
expect(allowed.status).toBe(200);
|
||||
expect(allowed.body).toEqual([{ teamId: 'team-alpha', userId: 'user-1' }]);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId allows a self-lookup on any team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: false });
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId denies looking up another user on a foreign team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-2');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('an admin can look up any membership', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: true });
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,68 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
type RequestUser = { id: string; role?: string };
|
||||
|
||||
@Controller('api/teams')
|
||||
@UseGuards(AuthGuard)
|
||||
export class TeamsController {
|
||||
constructor(private readonly teams: TeamsService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.teams.findAll();
|
||||
async list(@CurrentUser() user: RequestUser) {
|
||||
if (user.role === 'admin') {
|
||||
return this.teams.findAll();
|
||||
}
|
||||
return this.teams.findAllForUser(user.id);
|
||||
}
|
||||
|
||||
@Get(':teamId')
|
||||
async findOne(@Param('teamId') teamId: string) {
|
||||
return this.teams.findById(teamId);
|
||||
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
return this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
|
||||
@Get(':teamId/members')
|
||||
async listMembers(@Param('teamId') teamId: string) {
|
||||
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
return this.teams.listMembers(teamId);
|
||||
}
|
||||
|
||||
@Get(':teamId/members/:userId')
|
||||
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||
async checkMembership(
|
||||
@Param('teamId') teamId: string,
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() user: RequestUser,
|
||||
) {
|
||||
// A user may always ask about their own membership; anything else is
|
||||
// team-scoped like the other routes.
|
||||
if (userId !== user.id) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
const isMember = await this.teams.isMember(teamId, userId);
|
||||
return { isMember };
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-scoped access: admins see any team; everyone else only teams they
|
||||
* are a member of. NotFoundException when the team does not exist and
|
||||
* ForbiddenException when the user lacks access (same convention as the
|
||||
* projects controller).
|
||||
*/
|
||||
private async getAccessibleTeam(teamId: string, user: RequestUser) {
|
||||
const team = await this.teams.findById(teamId);
|
||||
if (!team) throw new NotFoundException('Team not found');
|
||||
if (user.role === 'admin') return team;
|
||||
const isMember = await this.teams.isMember(teamId, user.id);
|
||||
if (!isMember) throw new ForbiddenException('Not a member of this team');
|
||||
return team;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -56,6 +56,21 @@ export class TeamsService {
|
||||
return this.db.select().from(teams);
|
||||
}
|
||||
|
||||
/**
|
||||
* List only the teams the user is a member of.
|
||||
*/
|
||||
async findAllForUser(userId: string) {
|
||||
const memberRows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, userId));
|
||||
|
||||
const teamIds = memberRows.map((r) => r.teamId);
|
||||
if (teamIds.length === 0) return [];
|
||||
|
||||
return this.db.select().from(teams).where(inArray(teams.id, teamIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a team by ID.
|
||||
*/
|
||||
|
||||
@@ -376,8 +376,9 @@ Session cleanup is scoped to one session identifier and only removes that sessio
|
||||
|
||||
### Web App
|
||||
|
||||
The web app uses origin-relative paths only; it needs no gateway URL variable.
|
||||
(`NEXT_PUBLIC_GATEWAY_URL` is obsolete and no longer read by anything.)
|
||||
| Variable | Default | Description |
|
||||
| ------------------------- | ------------------------ | -------------------------------------- |
|
||||
| `NEXT_PUBLIC_GATEWAY_URL` | `http://localhost:14242` | Gateway URL used by the Next.js client |
|
||||
|
||||
### Coordination
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
# Deployment Mode and Conversion Contract (D3)
|
||||
|
||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 6 of 9).
|
||||
Authority: PRD D3 (Part I §3) — two modes chosen at install time,
|
||||
Standalone and Enterprise, with the mode table (brains, user-data
|
||||
isolation, secrets, conversion); Standalone → Enterprise conversion is
|
||||
**one-way** and Enterprise is a **terminal state**. PRD D14 (Part I §7)
|
||||
— the per-user brain split is optional in Standalone and keeping it is
|
||||
the recommended default because it preserves forward-compatibility with
|
||||
the one-way conversion. PRD D11 (Part I §9) — v1 ships the Standalone
|
||||
flow only; Enterprise conversion is explicitly deferred. PRD D3
|
||||
federation clause — federation is intentionally not fully designed,
|
||||
deferred, and nothing in v1 may foreclose it.
|
||||
|
||||
Revision 2 (luna review F1–F7): the identity precondition restated in
|
||||
identity-contract terms with a conversion-local acknowledgment record
|
||||
this contract owns (F1); a durable, keyed preparation state with a
|
||||
Standalone-safe representation rule, an in-transaction re-check fence,
|
||||
and an exact flip boundary (F2); the §5.4 unknown-value rule stated
|
||||
directly without the contradictory non-exhaustiveness clause (F3); the
|
||||
D14 boundary bound here with a stable column-allowlist witness instead
|
||||
of delegated to an unratified layout (F4); the conversion witness
|
||||
matrix extended to every §4.2/§4.4 condition (F5); the mode-record
|
||||
writer coverage imported concretely from contract 1 §6.3 with a named
|
||||
schema, closed writer set, crafted-write probe, and mode-resolution
|
||||
assertion (F6); the mode read command flagged as a §12.1 drafting
|
||||
addition rather than a D8 mandate (F7). Ownership language aligned
|
||||
with contract 3 revision 2: mode is recorded at bootstrap and read by
|
||||
the wizard as input.
|
||||
|
||||
This contract binds the mode as a canonical platform property (§2), the
|
||||
per-mode obligations and which contract owns each (§3), the conversion
|
||||
transition (§4), the v1 non-foreclosure obligations (§5), and their
|
||||
witnesses (§6). Domain semantics stay with their owning contracts:
|
||||
wizard branching (contract 3 §2), identity/SSO
|
||||
(`identity-lifecycle.md`), custody and per-user brain mechanics
|
||||
(contract 7, `custody-schema.md`), tool mapping
|
||||
(`tool-gateway-mapping.md`).
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
1. **Mode**: the platform-wide deployment mode, exactly one of
|
||||
`standalone` or `enterprise`. The vocabulary is closed in v1;
|
||||
extension (e.g. a federation mode) is by amendment to this contract,
|
||||
never ad hoc.
|
||||
2. **Conversion**: the one-way transition `standalone → enterprise`.
|
||||
No other mode transition exists.
|
||||
3. **Conversion preconditions**: the verifiable conditions of §4.2 that
|
||||
must all hold before the mode record may change.
|
||||
4. **Preparation unit**: one re-runnable piece of pre-conversion work —
|
||||
the migration of one secret to the Vault backend, or the partition
|
||||
of one user's brain content (§4.3).
|
||||
|
||||
## 2. Mode is a canonical recorded property
|
||||
|
||||
1. Mode is recorded canonically in the platform database at bootstrap
|
||||
as the operator's install-time choice (D3: modes are "chosen at
|
||||
install time"). The record is a single-row keyed record
|
||||
(`platform_mode`: mode value, recorded-at timestamp, bootstrap epoch
|
||||
reference); this contract owns it, the bootstrap writer performs the
|
||||
one v1 write (§6.2), and the wizard reads it as input (contract 3
|
||||
§2.3). Mode is never derived from feature state (presence of Vault,
|
||||
count of brains, count of users), and no component may infer a
|
||||
different mode than the record states.
|
||||
2. The record is readable by any authenticated user through a Gateway
|
||||
command with CLI exposure. This read command is a **drafting
|
||||
addition** ratified with this contract (PRD §12.1), not a D8
|
||||
mandate: D8 binds only that any surface exposing the value goes
|
||||
through official tooling. When a webUI surface consumes the read, a
|
||||
mapping row is added to `tool-gateway-mapping.md` by amendment —
|
||||
the same route §4.4 already binds for the conversion command.
|
||||
Components branch on the read value only.
|
||||
3. The record is immutable except by the §4 conversion transition.
|
||||
Editing it by direct database access, config file, environment
|
||||
variable, or wizard re-run is non-conformant (contract 3 §2.3:
|
||||
changing mode later is conversion, not a wizard re-run).
|
||||
|
||||
## 3. Per-mode obligations (owner map)
|
||||
|
||||
The PRD mode table binds four rows; this contract assigns each an
|
||||
owning contract so no obligation is unowned and none is bound twice:
|
||||
|
||||
| Obligation | Standalone | Enterprise | Owner |
|
||||
| ------------------- | -------------------------------------- | -------------------------------------------------- | --------------------------------------------- |
|
||||
| Brains | one mosaic-brain (system + user files) | system brain for config + one brain per user | contract 7 (custody/brain mechanics) |
|
||||
| User-data isolation | single user | no user-data leakage between users; sharing opt-in | contract 7 (enforced by architecture, D14) |
|
||||
| Secrets | OpenBao/Vault or flat files | OpenBao/Vault REQUIRED | this contract (§4.2 gate; steady-state check) |
|
||||
| Conversion | may convert to Enterprise, one-way | terminal state | this contract (§4) |
|
||||
|
||||
The Standalone brains row states the default layout, not the only
|
||||
valid one: the D14 per-user split is a MAY in Standalone with keeping
|
||||
it the recommended default (PRD §7, contract 7 §6), and Vault-backed
|
||||
secrets are equally valid Standalone configuration. Both prepared
|
||||
states are therefore themselves valid Standalone states — the fact
|
||||
§4.3 relies on.
|
||||
|
||||
In Enterprise steady state, a flat-file secrets backend is
|
||||
non-conformant; the platform refuses to start Enterprise-mode
|
||||
components against a flat-file secrets configuration (fail-closed, not
|
||||
warn-and-run).
|
||||
|
||||
## 4. Conversion transition
|
||||
|
||||
1. **Direction and terminality.** The only transition is
|
||||
`standalone → enterprise`. `enterprise → standalone` does not exist:
|
||||
there is no command, no admin override, and no support path. An
|
||||
attempt is refused with the precondition/state error class of the
|
||||
command envelope (`tool-gateway-mapping.md` §4.2).
|
||||
2. **Preconditions (all verified before the record changes):**
|
||||
- Secrets: OpenBao/Vault is configured and reachable, and every
|
||||
required secret is served from the Vault backend — none from a
|
||||
flat-file backend. Secret migration completes before conversion;
|
||||
this contract does not define the migration tooling, only the
|
||||
gate.
|
||||
- Brains: the per-user brain split required by the Enterprise row of
|
||||
§3 is established for **every** existing user (or the deployment
|
||||
already kept the split, the D14 recommended default). Brain
|
||||
partitioning mechanics are contract 7; this contract binds only
|
||||
that the split is complete before the mode flips.
|
||||
- Identity: at least one platform administrator account exists that
|
||||
is active in identity-contract terms — authenticated capability,
|
||||
not banned, not deactivated (identity §2, §5). And the conversion
|
||||
request carries a **configuration acknowledgment**: the current
|
||||
canonical values of registration mode and per-provider JIT
|
||||
enablement (identity §2.2, §4.1), echoed back in the request. A
|
||||
mismatch between the echoed values and the canonical values at
|
||||
verification refuses the conversion. This acknowledgment record
|
||||
is conversion-local, owned by this contract, and stored with the
|
||||
§4.4 audit event as the precondition evidence; it adds no
|
||||
identity-contract obligation and no mode-specific identity
|
||||
default — identity's own defaults remain valid states.
|
||||
3. **Preparation state and the flip boundary.** Preparatory work is
|
||||
tracked durably: each preparation unit (§1.4) records its
|
||||
completion in a preparation table keyed by (bootstrap epoch, unit
|
||||
identity — the secret's path, the user's id), written in the same
|
||||
transaction as the unit's own effect where the unit's backend
|
||||
allows it, and reconciled from the backend's actual state where it
|
||||
does not (a secret already served by Vault, a brain already split,
|
||||
is complete regardless of the table). Units are at-most-once per
|
||||
key and re-runnable across attempts. **Standalone-safe
|
||||
representation:** every preparation unit moves the deployment into
|
||||
a state that is itself valid Standalone configuration (§3 note), so
|
||||
an interrupted preparation leaves a fully operational Standalone
|
||||
deployment reading its state through the ordinary contracts — no
|
||||
rollback, fencing, or special Standalone read path is needed, and
|
||||
no component behavior may key on "preparation in progress".
|
||||
**The flip:** one transaction that (a) locks the mode record, (b)
|
||||
re-verifies every §4.2 precondition after acquiring the lock, and
|
||||
(c) writes the mode record and the §4.4 audit event. Any re-check
|
||||
failure aborts with no write. External state that changes after the
|
||||
re-check but before commit is bounded by the transaction window;
|
||||
an external backend (Vault) failing after conversion is an
|
||||
Enterprise runtime fault handled by §3's fail-closed steady-state
|
||||
rule, not a conversion defect. An interrupted or failed conversion
|
||||
leaves the record `standalone` and the platform fully operational;
|
||||
there is no intermediate mode and no half-converted state
|
||||
observable through the record.
|
||||
4. **Authority and audit.** Conversion is a platform-administrator
|
||||
command carrying an explicit irreversibility acknowledgment in its
|
||||
request (distinct from the §4.2 configuration acknowledgment). It
|
||||
is an official Gateway/CLI command (D8): when built, it is added to
|
||||
the tool↔Gateway mapping by amendment (`tool-gateway-mapping.md`
|
||||
§3.3). The transition emits an audit event (actor, prior mode, new
|
||||
mode, precondition evidence reference including the configuration
|
||||
acknowledgment) in the same transaction as the record change; the
|
||||
event survives indefinitely. A refused attempt emits a refusal
|
||||
event naming the failed precondition class and actor, with no
|
||||
mode-change event.
|
||||
|
||||
## 5. v1 obligations (non-foreclosure)
|
||||
|
||||
v1 ships Standalone only (D11); the conversion command is deferred
|
||||
work. v1 still MUST:
|
||||
|
||||
1. Record the mode per §2 at bootstrap, with `enterprise` a reserved,
|
||||
refused value for bootstrap — v1 bootstrap accepts `standalone`
|
||||
only. The wizard reads the record (contract 3 §2.3); nothing in v1
|
||||
writes it after bootstrap.
|
||||
2. Keep the §2.3 immutability rule: no v1 surface mutates the mode
|
||||
record.
|
||||
3. Not foreclose conversion: the v1 platform database holds no
|
||||
sensitive user content — sensitive categories live in the owning
|
||||
user's brain, and postgres holds structure, consent records, and
|
||||
pointers only (the D14 boundary, PRD §7). Custody mechanics are
|
||||
contract 7's; this contract binds the boundary itself here so v1
|
||||
cannot ship a layout that makes the §4.2 brain precondition
|
||||
unsatisfiable, and §6.3 gives it a stable witness that does not
|
||||
depend on contract 7's internals. Conversion implementation
|
||||
additionally requires contract 7 ratified.
|
||||
4. Not foreclose federation: v1 components accept exactly the two §1.1
|
||||
values wherever a mode value is parsed and refuse any other value
|
||||
**before side effects** — a refused configuration, not undefined
|
||||
behavior and not a crash mid-operation. Forward compatibility lives
|
||||
in storage and architecture, not in parser speculation: the mode
|
||||
record's storage is not structurally locked to two values (no
|
||||
database-level two-value enum), and any future value (e.g. a
|
||||
federation mode) is defined by a versioned amendment to this
|
||||
contract before any component accepts it. The PRD defers
|
||||
federation's shape entirely; this contract does not presume it
|
||||
arrives as a third mode value.
|
||||
|
||||
## 6. Verification requirements
|
||||
|
||||
Binding on the implementing PRs:
|
||||
|
||||
1. **Mode-record witness (v1):** after bootstrap the mode is readable
|
||||
via the Gateway command and CLI and equals the bootstrap-recorded
|
||||
choice; bootstrap with mode `enterprise` is refused; bootstrap with
|
||||
any unknown mode value is refused before side effects (§5.4).
|
||||
2. **Writer-coverage witness (v1):** the mode record's writer set is
|
||||
closed by the same three-prong static assertion contract 1 §6.3(b)
|
||||
defines — symbol, class-table literal, and raw-execution prongs
|
||||
with its allowlist composition rules — scoped to the
|
||||
`platform_mode` table, with a writer allowlist containing exactly
|
||||
the bootstrap writer in v1 (and exactly plus the conversion command
|
||||
at the conversion milestone). Companions: a crafted direct write
|
||||
attempted in a test fails and leaves the record unchanged; a
|
||||
mode-resolution assertion that no shipped component derives mode
|
||||
from feature state (mode reads occur only through the §2.2 read
|
||||
surface — static assertion over Gateway, CLI, bootstrap, and
|
||||
repository sources).
|
||||
3. **D14-boundary witness (v1):** a column-allowlist assertion in the
|
||||
style of contract 1 §6.2 that the platform database schema contains
|
||||
no sensitive-content column — the §5.3 boundary — stable regardless
|
||||
of contract 7's internals (contract 7 §7 carries the full custody
|
||||
witnesses).
|
||||
4. **No-downgrade witness (conversion milestone):** with mode
|
||||
`enterprise`, a conversion request to `standalone` (and any crafted
|
||||
mode-write) is refused with the precondition/state error class and
|
||||
no record change.
|
||||
5. **Precondition witnesses (conversion milestone),** each refused
|
||||
with no record change and no partial mode effect, parameterized
|
||||
over both OpenBao and Vault where secrets are involved:
|
||||
(a) secrets backend unreachable; (b) one required secret still
|
||||
flat-file backed (migration incomplete); (c) one unpartitioned user
|
||||
brain in a **multi-user** deployment where every other user is
|
||||
partitioned; (d) no active platform administrator (the only admin
|
||||
banned or deactivated); (e) configuration acknowledgment missing or
|
||||
mismatching the canonical registration/JIT values; (f) actor not a
|
||||
platform administrator (authorization refusal); (g) irreversibility
|
||||
acknowledgment absent. And the steady-state rule: an
|
||||
Enterprise-mode component started against a flat-file secrets
|
||||
configuration refuses to start (§3).
|
||||
6. **Interruption and fence witnesses (conversion milestone):** fault
|
||||
injection aborting conversion after each preparation unit and
|
||||
between preparation and flip leaves the record `standalone` and the
|
||||
platform operational in Standalone semantics (§4.3
|
||||
Standalone-safety), and a re-attempt completes without duplicating
|
||||
prepared state (at-most-once keys); a precondition invalidated
|
||||
after preparation but before the flip (a secret reverted to
|
||||
flat-file) is caught by the in-transaction re-check and refused.
|
||||
7. **Audit witnesses (conversion milestone):** a completed conversion
|
||||
has exactly one mode-change audit event, same-transaction with the
|
||||
record change (transaction linkage asserted), carrying actor, prior
|
||||
mode, new mode, and the precondition evidence reference including
|
||||
the configuration acknowledgment; a failed attempt has a refusal
|
||||
event naming the failed precondition class and no mode-change
|
||||
event; the mode-change event remains queryable after subsequent
|
||||
unrelated audit activity (retention probe).
|
||||
8. **Mapping witness (conversion milestone):** the conversion command
|
||||
and the mode read command each have their
|
||||
`tool-gateway-mapping.md` row (added by amendment per §2.2/§4.4)
|
||||
before the commands ship.
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–6 as written, with one decision embedded:
|
||||
|
||||
- Decision (§5): v1 implements the **mode record and its immutability
|
||||
only** — bootstrap records `standalone`, the `enterprise` value is
|
||||
reserved and refused, and the conversion command itself is deferred
|
||||
to the Enterprise milestone, consistent with D11's deferred list.
|
||||
v1 carries three obligations beyond the record: the closed writer
|
||||
assertion, the D14 column boundary, and the unknown-value refusal
|
||||
(§6.1–§6.3) — these are the non-foreclosure floor, not hidden
|
||||
conversion work. Alternative if rejected: build the conversion
|
||||
command inside v1 — rejected because D11 scopes v1 to the Standalone
|
||||
slice and conversion depends on contract 7 custody mechanics that
|
||||
are themselves not in the v1 slice.
|
||||
@@ -1,7 +1,7 @@
|
||||
# WebUI Phase P — File / Folder Structure & Migration Map
|
||||
|
||||
> **Status:** living document. Structure and increment status are verified against `next` as of
|
||||
> merge `3bd490c0` (2026-08-26). Details (per-surface component inventories, exact route tables,
|
||||
> **Status:** living document — first pass. Structure and increment status are verified against
|
||||
> `next` as of merge `8c27024d`. Details (per-surface component inventories, exact route tables,
|
||||
> test matrices) are still being fleshed out; extend the stub sections below rather than rewriting
|
||||
> the verified structure.
|
||||
|
||||
@@ -29,8 +29,7 @@ apps/web/
|
||||
├── routes.tsx # ── SPA React Router route table
|
||||
├── spa/ # ── NEW SPA surfaces
|
||||
│ ├── guards.tsx # guest / authenticated route guards
|
||||
│ ├── pages/ # login, register, sso-callback (P2); chat + error boundary (P3);
|
||||
│ │ # projects, project-detail, tasks (P4-1)
|
||||
│ ├── pages/ # login, register, sso-callback (P2); chat + error boundary (P3)
|
||||
│ └── chat/ # P3 typed chat: use-chat-connection, commands-panel,
|
||||
│ # session-panel, message-transcript, tool-call-list, composer
|
||||
│
|
||||
@@ -81,14 +80,14 @@ build path is retired.
|
||||
|
||||
## 5. Increment map (P1–P6)
|
||||
|
||||
| # | Increment | Branch | Status |
|
||||
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| **P1** | Vite + React Router skeleton beside Next (entry, router, guards, vitest) | `feat/webui-p1-vite-skeleton` | ✅ merged — PR **#1143** |
|
||||
| **P2** | SPA data layer + same-origin auth (login/register/SSO pages, guards, relative api/socket/auth-client) | `feat/webui-p2-data-auth` | ✅ merged — PR **#1144** |
|
||||
| **P3** | Typed SPA **chat** (`spa/chat/*`, `chat-contract.ts`, chat page + error boundary) | `feat/webui-p3-chat` | ✅ merged — PR **#1151** (+ repair PR **#1154**) |
|
||||
| **P4** | Port **projects / tasks / settings / admin** dashboard surfaces into the SPA | `feat/webui-p4-1` | 🚧 in progress — P4-1 (read-only projects + tasks) merged, PR **#1153**; settings/admin remain |
|
||||
| **P5** | **Cutover**: Gateway serves the Vite `dist` on `:14242`; flip `dev`/`build` to vite; **remove** the legacy Next `app/` tree + `next.config.ts` | _tbd_ | ⏳ not started |
|
||||
| **P6** | CI / images (trails): build the SPA in CI, ship images | _tbd_ | ⏳ trails |
|
||||
| # | Increment | Branch | Status |
|
||||
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ------------------------- |
|
||||
| **P1** | Vite + React Router skeleton beside Next (entry, router, guards, vitest) | `feat/webui-p1-vite-skeleton` | ✅ merged — PR **#1143** |
|
||||
| **P2** | SPA data layer + same-origin auth (login/register/SSO pages, guards, relative api/socket/auth-client) | `feat/webui-p2-data-auth` | ✅ merged — PR **#1144** |
|
||||
| **P3** | Typed SPA **chat** (`spa/chat/*`, `chat-contract.ts`, chat page + error boundary) | `feat/webui-p3-chat` | 🚧 in progress (unmerged) |
|
||||
| **P4** | Port **projects / tasks / settings / admin** dashboard surfaces into the SPA | _tbd_ | ⏳ not started |
|
||||
| **P5** | **Cutover**: Gateway serves the Vite `dist` on `:14242`; flip `dev`/`build` to vite; **remove** the legacy Next `app/` tree + `next.config.ts` | _tbd_ | ⏳ not started |
|
||||
| **P6** | CI / images (trails): build the SPA in CI, ship images | _tbd_ | ⏳ trails |
|
||||
|
||||
Each increment follows the same delivery pipeline: brief traceable to the RFC → author →
|
||||
**independent** integrator verification (build+test+typecheck+lint) → **independent** code + security
|
||||
|
||||
Reference in New Issue
Block a user