Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a17362ec |
+4
-6
@@ -38,12 +38,10 @@ when:
|
|||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
|
||||||
# Turbo remote cache (turbo.mosaicstack.dev) is wired in publish.yml via the
|
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||||
# org-level Woodpecker secret `turbo_token` (events: push/tag/cron/manual/
|
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||||
# deployment — never pull_request). This PR pipeline deliberately gets no
|
# This avoids from_secret which is blocked on pull_request events.
|
||||||
# remote-cache credentials: an untrusted PR must not be able to write to (or
|
# If the env vars aren't set, turbo falls back to local cache only.
|
||||||
# poison) the shared cache. Without TURBO_* env vars turbo falls back to
|
|
||||||
# local cache only, which is the intended behavior here.
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
install:
|
install:
|
||||||
|
|||||||
+14
-41
@@ -32,11 +32,6 @@ variables:
|
|||||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
# 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
|
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||||
# the separate `event: tag` entry.)
|
# 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
|
- &image_build_when
|
||||||
- event: tag
|
- event: tag
|
||||||
- event: [push, manual]
|
- event: [push, manual]
|
||||||
@@ -49,6 +44,16 @@ variables:
|
|||||||
- '.woodpecker/**'
|
- '.woodpecker/**'
|
||||||
- event: [push, manual]
|
- event: [push, manual]
|
||||||
branch: next
|
branch: next
|
||||||
|
- &main_image_build_when
|
||||||
|
- event: tag
|
||||||
|
- event: [push, manual]
|
||||||
|
branch: main
|
||||||
|
path:
|
||||||
|
exclude:
|
||||||
|
- 'packages/mosaic/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- '**/*.md'
|
||||||
|
- '.woodpecker/**'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- branch: [main, next]
|
- branch: [main, next]
|
||||||
@@ -68,13 +73,6 @@ steps:
|
|||||||
# being empty) and on any incomplete verification.
|
# being empty) and on any incomplete verification.
|
||||||
verify:
|
verify:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
|
|
||||||
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
||||||
@@ -110,13 +108,6 @@ steps:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
|
|
||||||
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- pnpm build
|
- pnpm build
|
||||||
@@ -469,7 +460,7 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: REGISTRY_USERNAME
|
||||||
@@ -483,17 +474,8 @@ steps:
|
|||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- 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}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; 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"
|
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
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||||
@@ -513,7 +495,7 @@ steps:
|
|||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: REGISTRY_USERNAME
|
||||||
@@ -527,17 +509,8 @@ steps:
|
|||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- 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}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; 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"
|
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
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
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,68 +1,30 @@
|
|||||||
import {
|
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
ForbiddenException,
|
|
||||||
Get,
|
|
||||||
NotFoundException,
|
|
||||||
Param,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
import { TeamsService } from './teams.service.js';
|
import { TeamsService } from './teams.service.js';
|
||||||
|
|
||||||
type RequestUser = { id: string; role?: string };
|
|
||||||
|
|
||||||
@Controller('api/teams')
|
@Controller('api/teams')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class TeamsController {
|
export class TeamsController {
|
||||||
constructor(private readonly teams: TeamsService) {}
|
constructor(private readonly teams: TeamsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@CurrentUser() user: RequestUser) {
|
async list() {
|
||||||
if (user.role === 'admin') {
|
return this.teams.findAll();
|
||||||
return this.teams.findAll();
|
|
||||||
}
|
|
||||||
return this.teams.findAllForUser(user.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId')
|
@Get(':teamId')
|
||||||
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
async findOne(@Param('teamId') teamId: string) {
|
||||||
return this.getAccessibleTeam(teamId, user);
|
return this.teams.findById(teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId/members')
|
@Get(':teamId/members')
|
||||||
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
async listMembers(@Param('teamId') teamId: string) {
|
||||||
await this.getAccessibleTeam(teamId, user);
|
|
||||||
return this.teams.listMembers(teamId);
|
return this.teams.listMembers(teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId/members/:userId')
|
@Get(':teamId/members/:userId')
|
||||||
async checkMembership(
|
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||||
@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);
|
const isMember = await this.teams.isMember(teamId, userId);
|
||||||
return { isMember };
|
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 { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -56,21 +56,6 @@ export class TeamsService {
|
|||||||
return this.db.select().from(teams);
|
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.
|
* Find a team by ID.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1014
-242
File diff suppressed because it is too large
Load Diff
@@ -1,77 +0,0 @@
|
|||||||
---
|
|
||||||
kind: spec
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Mosaic Stack Roadmap
|
|
||||||
|
|
||||||
Companion to [docs/PRD.md](./PRD.md). Governed by the D11 rule: **every planned
|
|
||||||
phase appears here from day one, even as a placeholder** — nothing exists only
|
|
||||||
in heads. A phase marked _placeholder_ is a commitment to design it, not a
|
|
||||||
design; scoping one requires its own PRD section or requirements doc plus
|
|
||||||
review.
|
|
||||||
|
|
||||||
Phases are product phases. The in-flight platform workstreams (KBN-100/101
|
|
||||||
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, and the other
|
|
||||||
Part II contracts in the PRD) run as parallel tracks under their own issues
|
|
||||||
and are prerequisites where noted.
|
|
||||||
|
|
||||||
| Phase | Scope | Status |
|
|
||||||
| ----- | ------------------------------------------------------------------------------ | ----------------------------------------- |
|
|
||||||
| P0 | Current state on `next`: read-only dashboard, chat, auth/SSO login, admin tabs | shipped, evolving |
|
|
||||||
| P1 | **v1 slice** (PRD Part I §9) | next up |
|
|
||||||
| P2 | Connectors + comms + wizard expansion | placeholder |
|
|
||||||
| P3 | Full onboarding profile + M365 | placeholder |
|
|
||||||
| P4 | Enterprise mode + one-way conversion | placeholder |
|
|
||||||
| P5 | Federation | placeholder (deliberately undesigned, D3) |
|
|
||||||
|
|
||||||
## P0 — current state
|
|
||||||
|
|
||||||
What exists on `next` today: web dashboard (login/register/SSO, chat,
|
|
||||||
read-only projects/tasks, settings, admin user/system-health tabs), the
|
|
||||||
Gateway, the CLI-first framework tooling, and the fleet control plane. The
|
|
||||||
webUI audit (USC estate, webui-audit lane) measures the gap between this and
|
|
||||||
P1.
|
|
||||||
|
|
||||||
## P1 — v1 slice (D11)
|
|
||||||
|
|
||||||
1. Standalone onboarding wizard: system/company name, component choices,
|
|
||||||
initial user, initial estate + project, seeded examples, re-runnable.
|
|
||||||
2. Hierarchy core: company → estate → project → workspace → kanban, read-only
|
|
||||||
task bubble-up (kanban SOT Amendment A1 is the schema contract).
|
|
||||||
3. Basic RBAC on the hierarchy.
|
|
||||||
4. Minimal agent enrollment: one harness, API key, name/persona.
|
|
||||||
|
|
||||||
Prerequisites: KBN-100/101 schema foundation; the D8 tool inventory and
|
|
||||||
webUI→tool mapping (any missing tool is built first, D12).
|
|
||||||
|
|
||||||
## P2 — connectors + comms + wizard expansion (placeholder)
|
|
||||||
|
|
||||||
Email and drive connectors (Gmail/IMAP, Google Drive/OneDrive/Dropbox) with
|
|
||||||
granular agentic-access consent; comms integrations (Matrix/Discord/Slack)
|
|
||||||
including agent auto-enroll. Wizard gains the corresponding tabs (D4), plus
|
|
||||||
the D4 capabilities deferred out of P1's minimal slice: expanded agent
|
|
||||||
enrollment (OAuth login, multi-account, model choice with recommendation,
|
|
||||||
account assignment, comms auto-enroll) and the Standalone SSO/OIDC
|
|
||||||
configuration tab.
|
|
||||||
|
|
||||||
## P3 — full onboarding profile + M365 (placeholder)
|
|
||||||
|
|
||||||
Complete user onboarding profile (communication-style capture, optional
|
|
||||||
voice-matching interview) under the D14 custody rule; M365 connectors,
|
|
||||||
available to both deployment modes as ordinary connectors (same consent model
|
|
||||||
as the P2 connector class). The Enterprise install flow's M365 prominence
|
|
||||||
(D4) arrives with the Enterprise phase, P4.
|
|
||||||
|
|
||||||
## P4 — Enterprise mode + conversion (placeholder)
|
|
||||||
|
|
||||||
Enterprise install flow (org chart, RBAC focus, immediate OIDC, SSO
|
|
||||||
prominent); per-user brains with architectural isolation (D14); Vault
|
|
||||||
required; the one-way Standalone → Enterprise conversion (D3).
|
|
||||||
|
|
||||||
## P5 — federation (placeholder)
|
|
||||||
|
|
||||||
Connecting deployments: system-level config, assigned users, rights and
|
|
||||||
data-access control, trusts with boundaries, strict data access, exfiltration
|
|
||||||
monitoring. Explicitly not designed yet (D3); nothing in earlier phases may
|
|
||||||
foreclose it. Requires its own PRD + threat model before any scoping.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,380 +0,0 @@
|
|||||||
# Hierarchy Schema Contract (D2)
|
|
||||||
|
|
||||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 1 of 9).
|
|
||||||
Authority: PRD D2/D9/D13 (Part I §4) and the native-kanban SOT Amendment A1
|
|
||||||
(`docs/requirements/native-kanban-sot.md` §8, ratified 2026-08-25). This
|
|
||||||
document turns the ratified hierarchy into a concrete schema contract:
|
|
||||||
tables, cardinalities, constraints, and ownership/transfer semantics. It is
|
|
||||||
the prerequisite for the hierarchy command family and for the RBAC grant
|
|
||||||
model (contract 2, `docs/requirements/rbac-grant-model.md`).
|
|
||||||
|
|
||||||
Revision 2 (independent review, GPT-5.6 terra): tenancy-FK exemption made
|
|
||||||
explicit (§1.1); record class extended to include `hierarchy_grants`
|
|
||||||
(§1.1); provenance corrections on legacy tables and the planning `projects`
|
|
||||||
table (§1.3, §2 naming note); NOT NULL and `NULLS NOT DISTINCT` grant
|
|
||||||
uniqueness (§2.6, §3.2); grant FK delete actions split cascade/restrict
|
|
||||||
(§3.3); transfer transaction includes its audit write (§4.3); ownership
|
|
||||||
invariant completed via contract 2 with the both-sides rule marked as new
|
|
||||||
policy (§4.2, §4.4); hierarchy audit brought under REQ-AUD-001-equivalent
|
|
||||||
guarantees with deletion-safe linkage (§5.2); roll-up never-a-write restored
|
|
||||||
to full A1 strength (§5.4); §6 rebuilt with bounded observables for every
|
|
||||||
MUST (allowlist, command surface, audit, corrected cardinality witness).
|
|
||||||
|
|
||||||
Revision 3 (terra re-review residuals): §4.3 transfer write inventory
|
|
||||||
reconciled with §5.2 — the transaction's writes are the single class-row
|
|
||||||
mutation plus that mutation's §5.2 audit writes (event + outbox record),
|
|
||||||
not "exactly two writes"; §6.3 extended with a closed writer-coverage
|
|
||||||
witness so an unregistered internal writer cannot pass a registered-route
|
|
||||||
inventory. (Terra's finding-8 residual — a stale contract 2 §7.8 backlink
|
|
||||||
to contract 1 §6.2 — was already fixed in contract 2 revision 2, which
|
|
||||||
cites §6.5; measured against `origin/contract/rbac-grants` head
|
|
||||||
`501112d2`.)
|
|
||||||
|
|
||||||
Revision 4 (terra r3 residual F7): the §6.3(b) writer-coverage assertion
|
|
||||||
extended to raw SQL — it now also fails on class-table name literals
|
|
||||||
inside SQL strings or tagged SQL templates outside the allowlist, so a
|
|
||||||
raw-SQL writer that touches no schema symbol is still caught.
|
|
||||||
|
|
||||||
Revision 5 (terra r4 residual F7): §6.3(b) gains a third prong — any
|
|
||||||
raw-SQL execution primitive outside the allowlist fails the assertion
|
|
||||||
regardless of its SQL content, closing the evasion where a
|
|
||||||
dynamically constructed table name carries neither a schema symbol nor
|
|
||||||
a class-table literal. The detection claim is now coextensive with
|
|
||||||
what the three prongs statically see.
|
|
||||||
|
|
||||||
Revision 6 (terra r5 residual F7 + new F8): the "two prongs" wording
|
|
||||||
corrected to three (F8); §6.3(b) gains the allowlist composition rules
|
|
||||||
(no generic raw-SQL helper is allowlisted; an allowlisted module may
|
|
||||||
not export caller-supplied-SQL execution) and fails outright on
|
|
||||||
runtime code-construction primitives; the detection claim is scoped
|
|
||||||
honestly to the stated syntactic forms, with evasions beyond static
|
|
||||||
reach assigned to §5.1 review/audit rather than claimed for CI.
|
|
||||||
|
|
||||||
Revision 7 (terra r6 new F9): the false-positive remedy no longer
|
|
||||||
contradicts the composition rules — legitimate non-hierarchy raw
|
|
||||||
execution (e.g. the db package's migration runner) is dispositioned
|
|
||||||
onto a second closed enumerated list, the infrastructure register,
|
|
||||||
exempt from prong (iii) only, still bound by prongs (i)/(ii), barred
|
|
||||||
from the writer allowlist, and importable only by registered modules
|
|
||||||
or the operational entry points.
|
|
||||||
|
|
||||||
Revision 8 (terra r7 residual F9): the register's import rule made
|
|
||||||
satisfiable by the live tree — imports are checked re-export-aware
|
|
||||||
(package barrels followed), and each registered module carries its own
|
|
||||||
closed importer enumeration, which may name operational entry points
|
|
||||||
such as the Gateway's startup migration hook; named importers stay
|
|
||||||
subject to prongs (i)/(ii) and gain no writer standing.
|
|
||||||
|
|
||||||
Revision 9 (terra r8 F10): revision 8 called the Gateway database
|
|
||||||
module the runner's "one live importer today". That was false — the
|
|
||||||
measured production importer set has four members. The enumeration
|
|
||||||
example now lists the complete measured set, and the import analysis
|
|
||||||
is extended to resolve literal dynamic `import()` routes, which two of
|
|
||||||
the four members use.
|
|
||||||
|
|
||||||
Scope: the tenancy/authorization structure record class — companies,
|
|
||||||
estates, platform-projects, workspaces, hierarchy grants, their parentage,
|
|
||||||
and constraints. Out of scope: the RBAC grant vocabulary and evaluation
|
|
||||||
semantics (contract 2), roll-up projection semantics (contract 8), kanban
|
|
||||||
planning entities inside workspaces (SOT §5), migration or retirement of
|
|
||||||
legacy flat data (future work; see §1.3).
|
|
||||||
|
|
||||||
## 1. Record class and placement
|
|
||||||
|
|
||||||
1. The **tenancy/authorization structure record class** defined by
|
|
||||||
Amendment A1 §8.1.2 comprises five tables: the four node tables of §2
|
|
||||||
AND `hierarchy_grants` (§3) — A1 includes hierarchy-level access grants
|
|
||||||
in the class. Every rule addressed to "the class" in this contract
|
|
||||||
(payload prohibition, mutation path, audit) binds all five tables. Class
|
|
||||||
rows carry parentage, naming, grant, and audit-linkage data only — never
|
|
||||||
task, plan, or any business/orchestration payload.
|
|
||||||
References from business/orchestration rows into the class are limited
|
|
||||||
to exactly one form: the canonical `workspace_id` tenancy column that
|
|
||||||
REQ-TEN-001 requires on every canonical row, referencing
|
|
||||||
`workspaces.id`. No business/orchestration row may reference a company,
|
|
||||||
estate, platform-project, or grant id in any position, and no
|
|
||||||
business/orchestration row may reference a workspace id in any
|
|
||||||
non-tenancy position (dependency, claim target, work subject).
|
|
||||||
2. Hierarchy records are NOT workspace-scoped rows: REQ-TEN-001's
|
|
||||||
`workspace_id` obligation binds business/orchestration rows and does not
|
|
||||||
apply to this class (A1 §8.1.2). The `workspaces` table itself is the
|
|
||||||
anchor the obligation points at.
|
|
||||||
3. The legacy flat tables (`teams`, and the Brain planning `projects` table
|
|
||||||
in `packages/db/src/schema.ts`) are not part of this class. What A1
|
|
||||||
§8.1.4 pins is narrower: the planning `projects` table and
|
|
||||||
`platform_projects` stay distinct tables. This contract adds, as new
|
|
||||||
policy ratified here: neither `teams` nor `projects` is repurposed as a
|
|
||||||
hierarchy table. Their eventual migration or retirement is future work
|
|
||||||
that no existing REQ assigns; it is out of scope here.
|
|
||||||
|
|
||||||
## 2. Tables and cardinalities
|
|
||||||
|
|
||||||
Naming: the level above workspaces is `platform_projects`, per A1 §8.1.4.
|
|
||||||
The existing `projects` table is Brain planning data (so labeled in
|
|
||||||
`packages/db/src/schema.ts`; it carries no `workspace_id`), and the schema
|
|
||||||
MUST NOT merge the two. (A rename of either remains an implementation-PR
|
|
||||||
decision under A1; this contract pins only that they stay distinct tables.)
|
|
||||||
|
|
||||||
1. `companies` — id (uuid pk), name, slug (unique per deployment),
|
|
||||||
created_at, updated_at. N per deployment (D2).
|
|
||||||
2. `estates` — id, name, slug, `company_id` NOT NULL →
|
|
||||||
`companies.id` ON DELETE RESTRICT. Exactly one company per estate; a
|
|
||||||
company holds any number of estates.
|
|
||||||
3. `platform_projects` — id, name, slug, `estate_id` NOT NULL →
|
|
||||||
`estates.id` ON DELETE RESTRICT. Exactly one estate per
|
|
||||||
platform-project; an estate holds any number of platform-projects.
|
|
||||||
4. `workspaces` — id, name, slug, `platform_project_id` NOT NULL →
|
|
||||||
`platform_projects.id` ON DELETE RESTRICT. Exactly one platform-project
|
|
||||||
per workspace. This table is the referent of every `workspace_id` column
|
|
||||||
the SOT requires on canonical rows.
|
|
||||||
5. **Chain resolution is by construction.** Because every parent FK is NOT
|
|
||||||
NULL and single-valued (one FK column, no parentage edge tables, no
|
|
||||||
multi-parent forms, no nullable "detached" states), each workspace
|
|
||||||
resolves to exactly one platform-project → estate → company chain (A1
|
|
||||||
§8.3 acceptance 1). One-parent-per-child is the constrained direction;
|
|
||||||
many children per parent is valid data.
|
|
||||||
6. **Slug scoping.** All `name` and `slug` columns are NOT NULL.
|
|
||||||
`estates.slug` is unique within its company, `platform_projects.slug`
|
|
||||||
within its estate, `workspaces.slug` within its platform-project
|
|
||||||
(composite unique constraints). Display names are unconstrained beyond
|
|
||||||
NOT NULL.
|
|
||||||
7. No hierarchy table carries a `metadata` jsonb column or any
|
|
||||||
free-form payload field. The columns declared in this section and §3
|
|
||||||
are exhaustive: a class table's column set is exactly its declared set
|
|
||||||
(verified per §6.2) — nothing else (A1 §8.1.2).
|
|
||||||
|
|
||||||
## 3. Grant attachment points
|
|
||||||
|
|
||||||
The grant vocabulary (which roles exist, what each permits, how evaluation
|
|
||||||
and revocation work) is contract 2. This contract pins only the schema
|
|
||||||
shape contract 2 attaches to:
|
|
||||||
|
|
||||||
1. `hierarchy_grants` — id, subject (exactly one of `user_id` → `users.id`,
|
|
||||||
`team_id` → `teams.id`; CHECK-enforced exactly-one-of), target (exactly
|
|
||||||
one of `company_id`, `estate_id`, `platform_project_id`;
|
|
||||||
CHECK-enforced exactly-one-of), `role` (text NOT NULL; vocabulary and
|
|
||||||
its CHECK constraint owned by contract 2 §2), `granted_by` NOT NULL →
|
|
||||||
`users.id`, created_at.
|
|
||||||
2. Uniqueness: at most one grant row per (subject, target, role). Because
|
|
||||||
the subject and target columns are nullable by design, ordinary
|
|
||||||
PostgreSQL composite uniqueness treats NULLs as distinct and would not
|
|
||||||
enforce this. The implementation MUST use a single
|
|
||||||
`UNIQUE NULLS NOT DISTINCT` constraint across (`user_id`, `team_id`,
|
|
||||||
`company_id`, `estate_id`, `platform_project_id`, `role`) or six
|
|
||||||
equivalent partial unique indexes (one per subject×target form). The
|
|
||||||
pinned Drizzle ORM supports `nullsNotDistinct()`.
|
|
||||||
3. Delete actions are split by column class:
|
|
||||||
- Target FKs (`company_id`, `estate_id`, `platform_project_id`):
|
|
||||||
ON DELETE CASCADE — the one permitted cascade in this class. A grant
|
|
||||||
on a deleted node is meaningless and fail-open if retained. Cascaded
|
|
||||||
grant deletions are audited per §5.2.
|
|
||||||
- Principal FKs (`user_id`, `team_id`, `granted_by`): ON DELETE
|
|
||||||
RESTRICT. The identity contract (§7.3) gates user deletion today and
|
|
||||||
defines no team-deletion rule; this contract does not invent one.
|
|
||||||
These FKs stay RESTRICT until an explicit deletion-and-retention
|
|
||||||
contract ratifies otherwise.
|
|
||||||
4. Workspace-level access is evaluated, not stored here: a grant at any of
|
|
||||||
the three levels evaluates down the chain to workspace-scoped
|
|
||||||
authorization (A1 §8.1.3). No `workspace_id` column exists on
|
|
||||||
`hierarchy_grants` — workspace membership (REQ-ID-001) remains its own
|
|
||||||
mechanism inside the SOT schema, and the chain adds where grants can be
|
|
||||||
declared, never a bypass.
|
|
||||||
|
|
||||||
## 4. Ownership and transfer
|
|
||||||
|
|
||||||
"Assets are transferable subject to the structure" (PRD Part I §4):
|
|
||||||
|
|
||||||
1. A transfer changes exactly one parent FK on exactly one hierarchy row:
|
|
||||||
workspace → new platform-project, platform-project → new estate, estate
|
|
||||||
→ new company. Nothing else in the class or the SOT changes: business
|
|
||||||
and orchestration rows inside affected workspaces are untouched, keep
|
|
||||||
their `workspace_id`, and never cross a workspace boundary (A1 §8.1.3
|
|
||||||
"chain maintenance").
|
|
||||||
2. Transfer authorization requires authority over BOTH the source and the
|
|
||||||
destination parent. This both-sides predicate is **new policy
|
|
||||||
introduced by this contract pair** (D2/A1 do not state it); its
|
|
||||||
evaluation semantics are contract 2 §5. The structural half — that the
|
|
||||||
transfer command evaluates it before mutating — binds here.
|
|
||||||
3. A transfer transaction mutates exactly one class-table row — the
|
|
||||||
single-row parent-FK update — and contains, beyond that, only the
|
|
||||||
§5.2 audit writes for that mutation (the audit event and its
|
|
||||||
hierarchy-outbox record, committing in the same transaction). No other
|
|
||||||
class, business, or orchestration row changes. There are no multi-row
|
|
||||||
transfer batches at the schema level; bulk moves are N audited
|
|
||||||
transfers.
|
|
||||||
4. Hierarchy records have no `owner_id`. Ownership in the hierarchy IS the
|
|
||||||
grant structure: a "company owner" is a subject with an `owner` grant
|
|
||||||
on that company or an ancestor (contract 2 §2), not a column. The
|
|
||||||
ownership invariant across the contract pair: a node may hold zero
|
|
||||||
direct owner grants (authority can derive from an ancestor grant); node
|
|
||||||
creation names the initial `owner` grant in the same audited operation
|
|
||||||
and the wizard seeds the first company's owner the same way (contract 2
|
|
||||||
§4.3); transfer and revocation semantics are contract 2 §§5–6. This
|
|
||||||
avoids column-encoded authority of the kind the legacy schema carries
|
|
||||||
(`teams.owner_id` and `teams.manager_id` are required user FKs, and
|
|
||||||
`team_members.role` is a further authority field — none of them
|
|
||||||
evaluable under a grant model).
|
|
||||||
|
|
||||||
## 5. Mutation path, audit, and deletion
|
|
||||||
|
|
||||||
1. All hierarchy mutations flow through the same sole-writable-SOT,
|
|
||||||
fail-closed, audited Gateway command path as everything else (A1 §8.2.3,
|
|
||||||
REQ-API-001). No direct-DB writers, no raw CRUD endpoints.
|
|
||||||
2. **Audit parity.** A1 §8.2 leaves every pre-existing REQ binding, so
|
|
||||||
hierarchy mutations get REQ-AUD-001's guarantees, not a weakened
|
|
||||||
substitute. Concretely:
|
|
||||||
- Every create, rename, transfer, grant create/change/revoke, and
|
|
||||||
delete — including every grant deletion cascaded by a node delete —
|
|
||||||
emits a semantic audit event carrying actor, verb, target, and (for
|
|
||||||
transfers) source and destination parents, with the correlation,
|
|
||||||
causation, idempotency, and per-target ordering guarantees REQ-AUD-001
|
|
||||||
defines.
|
|
||||||
- The state change and its audit event(s) commit in the same
|
|
||||||
transaction, delivered through a transactional outbox. Hierarchy
|
|
||||||
events are not workspace-scoped rows and do not ride the workspace
|
|
||||||
outbox; they get an equivalent hierarchy outbox under the same
|
|
||||||
append-only, same-transaction rules.
|
|
||||||
- **Deletion-safe linkage:** audit events reference their target by an
|
|
||||||
immutable snapshot (id, slug, and parent chain at event time), never
|
|
||||||
by a foreign key into the class tables, so append-only events survive
|
|
||||||
the deletion of their target.
|
|
||||||
3. Deletion is fail-closed bottom-up: a hierarchy record with children
|
|
||||||
cannot be deleted (RESTRICT FKs, §2). Deleting a workspace is a SOT-side
|
|
||||||
operation subject to the kanban SOT's own rules and is not granted any
|
|
||||||
new semantics by this contract.
|
|
||||||
4. **Roll-up is never a write** (A1 §8.2.2, preserved at full strength). A
|
|
||||||
roll-up read mutates nothing — not hierarchy state, and not business or
|
|
||||||
orchestration state: it must not mutate, claim, order, or gate
|
|
||||||
workspace work. Contract 8 owns projection details but cannot narrow
|
|
||||||
this rule. This contract additionally guarantees the chain roll-ups
|
|
||||||
aggregate over is unique and non-null (§2.5).
|
|
||||||
|
|
||||||
## 6. Verification requirements
|
|
||||||
|
|
||||||
Binding on the implementing PRs (extends A1 §8.3):
|
|
||||||
|
|
||||||
1. Schema witnesses (real PostgreSQL, §6.8): chain construction — insert
|
|
||||||
with a null parent FK refused; insert with one valid parent accepted;
|
|
||||||
two siblings under one parent accepted (the control proving the
|
|
||||||
constraint rejects only what §2.5 forbids); catalog assertion that each
|
|
||||||
child table has exactly one parent-FK column and no parentage edge
|
|
||||||
table exists. Composite slug uniqueness per parent (duplicate slug
|
|
||||||
under same parent refused; same slug under different parents accepted).
|
|
||||||
Grant CHECKs: exactly-one-of subject and exactly-one-of target each
|
|
||||||
witnessed (zero and two set → refused). Grant uniqueness: a duplicate
|
|
||||||
(subject, target, role) row refused for each of the six subject×target
|
|
||||||
forms, proving NULLS-NOT-DISTINCT semantics; NOT NULL on `role`,
|
|
||||||
`granted_by`, and all `name`/`slug` columns witnessed.
|
|
||||||
2. Column allowlist: an information_schema assertion that each class
|
|
||||||
table's column set is exactly the set declared in §2/§3 — the bounded
|
|
||||||
observable for no-payload (§2.7) and no-`owner_id` (§4.4).
|
|
||||||
3. Command surface: two witnesses, both required (§5.1). (a) Route
|
|
||||||
inventory: an assertion over the Gateway's registered hierarchy
|
|
||||||
routes/commands proving the registered mutation surface is exactly the
|
|
||||||
declared hierarchy command family — no generic CRUD endpoint. (b)
|
|
||||||
Writer coverage — the closed allowlist a route inventory cannot
|
|
||||||
provide: a static CI assertion over the Gateway and package sources
|
|
||||||
with three prongs, each bound to one explicitly enumerated allowlist
|
|
||||||
of hierarchy command/repository modules. (i) Symbol prong: write
|
|
||||||
references to the class-table schema symbols (insert, update, delete)
|
|
||||||
occur only in allowlisted modules. (ii) Literal prong: a class-table
|
|
||||||
name appearing inside a SQL string or tagged SQL template outside the
|
|
||||||
allowlist fails the assertion — this is what catches a raw-SQL writer
|
|
||||||
that references no schema symbol. (iii) Raw-execution prong: any call
|
|
||||||
to a raw-SQL execution primitive (the ORM's raw/unsafe constructors,
|
|
||||||
driver-level query/execute) outside the allowlist fails the
|
|
||||||
assertion, regardless of what the SQL string contains or how it is
|
|
||||||
constructed — the call site is statically detectable even when a
|
|
||||||
dynamically assembled table name is not, so a raw writer with a
|
|
||||||
runtime-built identifier is caught by its primitive, not its
|
|
||||||
payload. Two composition rules keep prong (iii) meaningful: the
|
|
||||||
allowlist names hierarchy command/repository modules only — a
|
|
||||||
generic raw-SQL helper or database-utility module is never
|
|
||||||
allowlisted; and an allowlisted module MUST NOT export a function
|
|
||||||
that executes caller-supplied SQL (such an export is itself a
|
|
||||||
raw-execution primitive, and the exporting module is treated as
|
|
||||||
unallowlisted for prong (iii) if it does). Legitimate raw execution
|
|
||||||
that is not a hierarchy writer — e.g. the migration runner in the
|
|
||||||
db package — lives on a second, separately enumerated
|
|
||||||
**infrastructure register**, distinct from the writer allowlist and
|
|
||||||
equally closed. A registered module is exempt from prong (iii) only:
|
|
||||||
prongs (i) and (ii) apply to it with no exemption, so it can hold no
|
|
||||||
class-table schema symbol or class-table SQL literal, and it can
|
|
||||||
never appear on the writer allowlist. To close the laundering path,
|
|
||||||
the same assertion checks imports, and the import analysis is
|
|
||||||
**re-export-aware**: it follows package barrels and re-exports, so a
|
|
||||||
route hidden behind an index module is still a route — and it
|
|
||||||
resolves literal dynamic imports the same way: an
|
|
||||||
`await import('<literal specifier>')` is an import edge like any
|
|
||||||
static import, not an evasion of the analysis (a dynamic import of
|
|
||||||
the db package whose specifier is not a literal fails the assertion
|
|
||||||
outright, because it makes the import graph unanalyzable). A
|
|
||||||
registered module may be imported only by other registered modules
|
|
||||||
or by importers named on that module's own closed importer
|
|
||||||
enumeration in the register — operational entry points such as the
|
|
||||||
migration/bootstrap CLI or the Gateway's startup migration hook.
|
|
||||||
The enumeration names the complete permitted production consumer
|
|
||||||
set, and completeness is measured, not asserted: the migration
|
|
||||||
runner's measured production importer set today has four members —
|
|
||||||
the Gateway database module (reached through the db package
|
|
||||||
barrel), the storage package's Postgres adapter, and two mosaic CLI
|
|
||||||
commands, the fleet-backlog command and the gateway verify command,
|
|
||||||
both routed through literal dynamic imports of the db package — so
|
|
||||||
its enumeration names those four. A module that only receives the
|
|
||||||
runner's functions by parameter injection (the gateway schema-check
|
|
||||||
module takes them as arguments from the verify command) has no
|
|
||||||
import edge of its own and is not enumerated. Any import route
|
|
||||||
outside the enumeration fails the assertion. Being a
|
|
||||||
named importer confers nothing else: the importer stays fully
|
|
||||||
subject to prongs (i) and (ii), gains no writer-allowlist standing,
|
|
||||||
and whether it uses the registered module beyond its operational
|
|
||||||
purpose is a §5.1 review question, not a static claim. Runtime code-construction
|
|
||||||
primitives (`eval`, `new Function`) anywhere in the scanned sources
|
|
||||||
fail the assertion outright, allowlist or not. Schema definitions
|
|
||||||
and generated migrations are excluded from the literal prong; a
|
|
||||||
false positive is resolved in the same PR by adding the module to
|
|
||||||
the one enumerated list its role permits — the writer allowlist for
|
|
||||||
a hierarchy command/repository module, the infrastructure register
|
|
||||||
for non-hierarchy raw execution — never by weakening the assertion,
|
|
||||||
and neither list may take a module the composition rules bar from
|
|
||||||
it. Both lists are closed, and the assertion's detection
|
|
||||||
claim is exactly its prongs: it statically surfaces every writer
|
|
||||||
expressed as a schema-symbol reference, a class-table SQL literal, a
|
|
||||||
raw-execution call site, or runtime code construction. An evasion
|
|
||||||
engineered outside those syntactic forms is a §5.1 violation that
|
|
||||||
review and audit own — the witness does not claim to catch what
|
|
||||||
static analysis cannot see, and any such evasion found later is
|
|
||||||
corrected as a conformance defect, not grandfathered.
|
|
||||||
4. Audit witnesses: for each mutation class (create, rename, transfer,
|
|
||||||
grant create/change/revoke, delete) — the event exists after commit
|
|
||||||
with actor/verb/target and same-transaction atomicity; a rolled-back
|
|
||||||
mutation leaves no event (rollback witness); a node delete's cascaded
|
|
||||||
grant deletions are each covered by events; events survive deletion of
|
|
||||||
their target (query the events of a deleted node).
|
|
||||||
5. Transfer tests: parent-FK update moves the subtree resolution and
|
|
||||||
modifies zero business/orchestration rows (row-count and content
|
|
||||||
assertions on workspace contents before/after); transfer without
|
|
||||||
authority on the source or on the destination side is refused (with
|
|
||||||
contract 2 §7.8).
|
|
||||||
6. Deletion tests: delete with children refused at the database level;
|
|
||||||
delete of a leaf cascades its grants and nothing else; deleting a user
|
|
||||||
or team that is a grant subject (or `granted_by` referent) is refused
|
|
||||||
(RESTRICT witnesses for §3.3).
|
|
||||||
7. Negative tests: no business/orchestration table accepts a company,
|
|
||||||
estate, platform-project, or grant id in any reference position, and
|
|
||||||
none accepts a workspace id in any non-tenancy position; the canonical
|
|
||||||
tenancy FK control — a business row inserted with a valid
|
|
||||||
`workspace_id` succeeds, with an invalid one is refused; roll-up
|
|
||||||
endpoints mutate no canonical state anywhere (assert zero writes across
|
|
||||||
hierarchy AND workspace tables, not hierarchy only); readers see
|
|
||||||
aggregates only over workspaces they are authorized on, with no
|
|
||||||
cross-tenant existence oracles (A1 §8.3 acceptance 3).
|
|
||||||
8. Real-PostgreSQL coverage for every constraint witness (unique/CHECK/
|
|
||||||
RESTRICT/NULLS NOT DISTINCT behavior), using the `ci-postgres` service
|
|
||||||
in the `test` CI step; mocked specs cannot witness database constraints.
|
|
||||||
|
|
||||||
## Ruling request
|
|
||||||
|
|
||||||
Ratify sections 1–6 as written, with one decision embedded: hierarchy
|
|
||||||
records carry no owner column — ownership is expressed solely through
|
|
||||||
grants (§4.4) — say "agreed" or name the ownership model you want.
|
|
||||||
@@ -372,87 +372,3 @@ The P0–P3 canon does not authorize:
|
|||||||
## 7. Global release evidence
|
## 7. Global release evidence
|
||||||
|
|
||||||
P0–P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence.
|
P0–P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence.
|
||||||
|
|
||||||
## 8. Amendment A1 — hierarchy parentage and RBAC chain above workspaces
|
|
||||||
|
|
||||||
**Status:** amendment to the ratified canon, added by reviewed PR under
|
|
||||||
decision D13 (operator ruling, 2026-08-25; decision owner Jason). It adds
|
|
||||||
parent structure ABOVE workspaces. Sections 1–7, every invariant in §3, and
|
|
||||||
every REQ above remain binding verbatim, with exactly one express modification:
|
|
||||||
the narrow portfolio-analytics carve-out stated in §8.2.4. Nothing else below
|
|
||||||
this line is weakened.
|
|
||||||
|
|
||||||
### 8.1 What is added
|
|
||||||
|
|
||||||
1. A platform hierarchy exists above workspaces:
|
|
||||||
**company/organization → estate → platform-project → workspace**. Each
|
|
||||||
workspace belongs to exactly one platform-project, each platform-project to
|
|
||||||
exactly one estate, each estate to exactly one company.
|
|
||||||
2. **Record class.** Hierarchy records (company, estate, platform-project,
|
|
||||||
their parentage edges, and hierarchy-level access grants) are a new,
|
|
||||||
explicitly named record class: **tenancy/authorization structure records**.
|
|
||||||
They are not business or orchestration records, so §3 invariant 10 and
|
|
||||||
REQ-TEN-001 do not apply to them and are not weakened by them — those two
|
|
||||||
requirements bind business/orchestration rows exactly as before.
|
|
||||||
Constraints on the new class:
|
|
||||||
- Hierarchy tables MUST NOT carry task, plan, or any other
|
|
||||||
business/orchestration payload — parentage, naming, and grant data only.
|
|
||||||
- A hierarchy record can never be the subject of work: it cannot be
|
|
||||||
claimed, ordered, gated, or referenced as a dependency by any
|
|
||||||
business/orchestration row.
|
|
||||||
- Hierarchy mutations flow through the same sole-writable-SOT, fail-closed,
|
|
||||||
audited mutation path as everything else (§8.2.3).
|
|
||||||
3. The hierarchy serves exactly two runtime functions, plus audited
|
|
||||||
maintenance of its own structure:
|
|
||||||
- **RBAC evaluation:** access grants are declared per company, estate, or
|
|
||||||
platform-project and evaluate down the chain to workspace-scoped
|
|
||||||
authorization. Tenant context continues to be derived from authenticated
|
|
||||||
authority (REQ-TEN-001); the chain adds where grants can be declared,
|
|
||||||
not a bypass of workspace authorization.
|
|
||||||
- **Read-only roll-ups:** task and status visualization bubbles up the
|
|
||||||
hierarchy as aggregation over workspaces the reader is authorized on.
|
|
||||||
- **Chain maintenance (not a third runtime function):** re-parenting an
|
|
||||||
asset — moving a workspace to another platform-project, a
|
|
||||||
platform-project to another estate, and so on ("assets are transferable
|
|
||||||
subject to the structure", PRD Part I §4) — is an audited edit of the
|
|
||||||
hierarchy records themselves under §8.3. It never modifies
|
|
||||||
business/orchestration rows and never crosses a workspace boundary for
|
|
||||||
them; the workspace's contents move with the workspace untouched.
|
|
||||||
4. Naming: this amendment says **platform-project** for the hierarchy level
|
|
||||||
above workspaces, because §5 REQ-PLAN-001 already defines `projects` as
|
|
||||||
planning entities INSIDE a workspace. The two are different objects. Final
|
|
||||||
terminology (rename of one or the other) is an implementation-PR decision
|
|
||||||
under this amendment's review; the schema MUST NOT merge them.
|
|
||||||
|
|
||||||
### 8.2 What is explicitly unchanged
|
|
||||||
|
|
||||||
1. `workspace_id` remains the hard mechanical isolation unit (§2 D2,
|
|
||||||
REQ-TEN-001). Hierarchy tables carry parentage; they do not create
|
|
||||||
cross-workspace relationships between business/orchestration rows, which
|
|
||||||
remain rejected (§3 invariant 10).
|
|
||||||
2. Roll-up is **never a write**: no aggregation path may mutate, claim, order,
|
|
||||||
or gate work in any workspace. Bubble-up views are generated projections in
|
|
||||||
the sense of §3 invariant 5 — non-authoritative and never import sources.
|
|
||||||
3. Fail-closed mutation health (§3 invariants 3–4), sole writable PostgreSQL
|
|
||||||
SOT, fencing, audit, and the Coordinator/Certifier authority rules are
|
|
||||||
untouched.
|
|
||||||
4. No §6 non-goal is authorized, with one express, narrow carve-out that this
|
|
||||||
amendment makes to the "portfolio analytics" non-goal: the read-only
|
|
||||||
roll-up of §8.1 — per-workspace task counts and statuses aggregated up the
|
|
||||||
parent chain, over workspaces the reader is authorized on — is in scope.
|
|
||||||
Everything beyond that boundary (metrics, trends, forecasting, scoring,
|
|
||||||
dashboards computed across workspaces, any derived analytic that is not a
|
|
||||||
direct count/status aggregation) remains a non-goal. This is an explicit
|
|
||||||
narrowing by amendment, not a claim that §6 is unchanged; every other §6
|
|
||||||
non-goal is untouched.
|
|
||||||
|
|
||||||
### 8.3 Acceptance (binding on the implementing PRs)
|
|
||||||
|
|
||||||
- Schema tests prove each workspace resolves to exactly one
|
|
||||||
platform-project/estate/company chain and that chain edits are audited.
|
|
||||||
- Authorization tests prove a grant at each hierarchy level yields exactly the
|
|
||||||
workspace permissions the chain implies, and that revocation up the chain
|
|
||||||
propagates.
|
|
||||||
- Negative tests prove roll-up endpoints cannot mutate state and that a
|
|
||||||
reader sees aggregates only over workspaces they are authorized on
|
|
||||||
(no cross-tenant existence oracles).
|
|
||||||
|
|||||||
@@ -172,10 +172,9 @@ export function registerGatewayCommand(program: Command): void {
|
|||||||
.command('recover-token')
|
.command('recover-token')
|
||||||
.description('Recover an admin token — prompts for login if no valid session exists')
|
.description('Recover an admin token — prompts for login if no valid session exists')
|
||||||
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
|
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
|
||||||
.option('-e, --email <email>', 'Headless: account email (password read from stdin line 2)')
|
.action(async (cmdOpts: { gateway?: string }) => {
|
||||||
.action(async (cmdOpts: { gateway?: string; email?: string }) => {
|
|
||||||
const { runRecoverToken } = await import('./gateway/token-ops.js');
|
const { runRecoverToken } = await import('./gateway/token-ops.js');
|
||||||
await runRecoverToken(cmdOpts.gateway, cmdOpts.email);
|
await runRecoverToken(cmdOpts.gateway);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── logs ───────────────────────────────────────────────────────────────
|
// ─── logs ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -95,17 +95,11 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
|
|||||||
// fatal (#1392): an install that reports success over an empty/partial
|
// fatal (#1392): an install that reports success over an empty/partial
|
||||||
// database is the exact T63 failure this command must never reproduce.
|
// database is the exact T63 failure this command must never reproduce.
|
||||||
let verifyResult: VerifyResult | undefined;
|
let verifyResult: VerifyResult | undefined;
|
||||||
let verificationThrew = false;
|
|
||||||
try {
|
try {
|
||||||
const { runPostInstallVerification } = await import('./verify.js');
|
const { runPostInstallVerification } = await import('./verify.js');
|
||||||
verifyResult = await runPostInstallVerification(configResult.host, configResult.port);
|
verifyResult = await runPostInstallVerification(configResult.host, configResult.port);
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Health/token/bootstrap courtesy failures are non-fatal, but a THROWN
|
// Non-fatal — verification is a courtesy
|
||||||
// schema verification must not let install report success either (N2,
|
|
||||||
// rev-code-02 review 285): mark it and treat as fatal below.
|
|
||||||
verificationThrew = true;
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
prompter.warn(`Post-install verification errored: ${msg}`);
|
|
||||||
}
|
}
|
||||||
if (verifyResult && verifyResult.schemaMigrated === false) {
|
if (verifyResult && verifyResult.schemaMigrated === false) {
|
||||||
prompter.warn(
|
prompter.warn(
|
||||||
@@ -113,12 +107,6 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
|
|||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (verificationThrew) {
|
|
||||||
prompter.warn(
|
|
||||||
'Gateway install ABORTED: post-install verification errored (see above); refusing to report success on an unverified database.',
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Stages normally return structured results for expected failures.
|
// Stages normally return structured results for expected failures.
|
||||||
// Anything that reaches here is an unexpected runtime error — render a
|
// Anything that reaches here is an unexpected runtime error — render a
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { Readable } from 'node:stream';
|
|
||||||
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
|
|
||||||
|
|
||||||
describe('readCredentialsFromPipedStdin — #1394 stdin dual path (real streams)', () => {
|
|
||||||
it('reads exactly two lines; email trimmed, password as-is', async () => {
|
|
||||||
const r = await readCredentialsFromPipedStdin(
|
|
||||||
Readable.from([' [email protected] \n', 'pw with spaces \n']),
|
|
||||||
);
|
|
||||||
expect(r.email).toBe('[email protected]');
|
|
||||||
expect(r.password).toBe('pw with spaces ');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('empty stdin → nulls (the headless-no-credentials shape)', async () => {
|
|
||||||
const r = await readCredentialsFromPipedStdin(Readable.from(['']));
|
|
||||||
expect(r).toEqual({ email: null, password: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('single line only → email set, password null', async () => {
|
|
||||||
const r = await readCredentialsFromPipedStdin(Readable.from(['only-email\n']));
|
|
||||||
expect(r.email).toBe('only-email');
|
|
||||||
expect(r.password).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops after two lines even if more follow', async () => {
|
|
||||||
const r = await readCredentialsFromPipedStdin(
|
|
||||||
Readable.from(['[email protected]\n', 'pw\n', 'extra\n', 'more\n']),
|
|
||||||
);
|
|
||||||
expect(r).toEqual({ email: '[email protected]', password: 'pw' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { createInterface } from 'node:readline';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read email + password as two lines from non-TTY stdin (the headless dual
|
|
||||||
* path for callers that cannot pass argv: printf 'email\npassword\n' | …).
|
|
||||||
* Caller gates on !isTTY; the password line is kept as-is (no trim —
|
|
||||||
* whitespace may be intentional).
|
|
||||||
*
|
|
||||||
* Separate module (not login.ts) so tests can exercise the REAL reader
|
|
||||||
* against real streams while token-ops specs mock this seam cleanly.
|
|
||||||
*/
|
|
||||||
export function readCredentialsFromPipedStdin(
|
|
||||||
input: NodeJS.ReadableStream = process.stdin,
|
|
||||||
): Promise<{ email: string | null; password: string | null }> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const lines: string[] = [];
|
|
||||||
const rl = createInterface({ input });
|
|
||||||
rl.on('line', (l) => {
|
|
||||||
lines.push(l);
|
|
||||||
if (lines.length >= 2) rl.close();
|
|
||||||
});
|
|
||||||
rl.on('close', () => {
|
|
||||||
resolve({ email: (lines[0] ?? '').trim() || null, password: lines[1] ?? null });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -16,20 +16,11 @@ vi.mock('./daemon.js', () => ({
|
|||||||
|
|
||||||
vi.mock('./login.js', () => ({
|
vi.mock('./login.js', () => ({
|
||||||
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
|
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
|
||||||
// promptLine/promptSecret are used by ensureSession on the TTY path; return fixed
|
// promptLine/promptSecret are used by ensureSession; return fixed values so tests don't block on stdin
|
||||||
// values so tests never block on stdin.
|
|
||||||
promptLine: vi.fn().mockResolvedValue('[email protected]'),
|
promptLine: vi.fn().mockResolvedValue('[email protected]'),
|
||||||
promptSecret: vi.fn().mockResolvedValue('test-password'),
|
promptSecret: vi.fn().mockResolvedValue('test-password'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// #1394: non-TTY runs resolve credentials from piped stdin instead of prompts.
|
|
||||||
vi.mock('./piped-credentials.js', () => ({
|
|
||||||
readCredentialsFromPipedStdin: vi.fn().mockResolvedValue({
|
|
||||||
email: '[email protected]',
|
|
||||||
password: 'test-password',
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
vi.stubGlobal('fetch', mockFetch);
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
@@ -74,7 +65,7 @@ describe('ensureSession', () => {
|
|||||||
expect(mockSignIn).not.toHaveBeenCalled();
|
expect(mockSignIn).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves piped-stdin credentials and signs in when stored session is invalid', async () => {
|
it('prompts for credentials and signs in when stored session is invalid', async () => {
|
||||||
mockLoadSession.mockReturnValueOnce({ cookie: 'old-cookie', userId: 'u1', email: '[email protected]' });
|
mockLoadSession.mockReturnValueOnce({ cookie: 'old-cookie', userId: 'u1', email: '[email protected]' });
|
||||||
mockValidateSession.mockResolvedValueOnce(false);
|
mockValidateSession.mockResolvedValueOnce(false);
|
||||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||||
@@ -85,7 +76,7 @@ describe('ensureSession', () => {
|
|||||||
expect(mockSaveSession).toHaveBeenCalledWith(baseUrl, newAuth);
|
expect(mockSaveSession).toHaveBeenCalledWith(baseUrl, newAuth);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves piped-stdin credentials when no session exists', async () => {
|
it('prompts for credentials when no session exists', async () => {
|
||||||
mockLoadSession.mockReturnValueOnce(null);
|
mockLoadSession.mockReturnValueOnce(null);
|
||||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||||
mockSignIn.mockResolvedValueOnce(newAuth);
|
mockSignIn.mockResolvedValueOnce(newAuth);
|
||||||
@@ -93,10 +84,6 @@ describe('ensureSession', () => {
|
|||||||
const cookie = await ensureSession(baseUrl);
|
const cookie = await ensureSession(baseUrl);
|
||||||
expect(cookie).toBe(fakeCookie);
|
expect(cookie).toBe(fakeCookie);
|
||||||
expect(mockSignIn).toHaveBeenCalled();
|
expect(mockSignIn).toHaveBeenCalled();
|
||||||
// The non-TTY path resolves credentials from the piped-stdin seam, not prompts.
|
|
||||||
expect(
|
|
||||||
vi.mocked(await import('./piped-credentials.js')).readCredentialsFromPipedStdin,
|
|
||||||
).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exits non-zero when signIn fails', async () => {
|
it('exits non-zero when signIn fails', async () => {
|
||||||
@@ -124,7 +111,7 @@ describe('runRecoverToken', () => {
|
|||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('signs in via piped stdin, mints a token, and persists it when no session exists', async () => {
|
it('prompts for login, mints a token, and persists it when no session exists', async () => {
|
||||||
mockLoadSession.mockReturnValueOnce(null);
|
mockLoadSession.mockReturnValueOnce(null);
|
||||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||||
mockSignIn.mockResolvedValueOnce(newAuth);
|
mockSignIn.mockResolvedValueOnce(newAuth);
|
||||||
|
|||||||
@@ -153,29 +153,4 @@ describe('resolveSchemaCheckConfigPath', () => {
|
|||||||
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
|
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives MOSAIC_CONFIG NO authority (N1, review 285): env never overrides file resolution', async () => {
|
|
||||||
const { resolveSchemaCheckConfigPath } = await import('./schema-check.js');
|
|
||||||
const daemonDir = mkdtempSync(join(tmpdir(), 'schema-check-env-'));
|
|
||||||
tmpDirs.push(daemonDir);
|
|
||||||
const daemonHome = join(daemonDir, '.config', 'mosaic', 'gateway');
|
|
||||||
mkdirSync(daemonHome, { recursive: true });
|
|
||||||
writeFileSync(join(daemonHome, 'mosaic.config.json'), JSON.stringify(LOCAL_CFG));
|
|
||||||
// A stale env var pointing at a DIFFERENT file must be ignored entirely:
|
|
||||||
const decoyDir = mkdtempSync(join(tmpdir(), 'schema-check-decoy-'));
|
|
||||||
tmpDirs.push(decoyDir);
|
|
||||||
const decoyPath = join(decoyDir, 'mosaic.config.json');
|
|
||||||
writeFileSync(decoyPath, JSON.stringify(STANDALONE_CFG));
|
|
||||||
|
|
||||||
const prevHome = process.env['HOME'];
|
|
||||||
vi.stubEnv('HOME', daemonDir);
|
|
||||||
vi.stubEnv('MOSAIC_CONFIG', decoyPath);
|
|
||||||
try {
|
|
||||||
const resolved = resolveSchemaCheckConfigPath();
|
|
||||||
expect(resolved).toBe(join(daemonHome, 'mosaic.config.json'));
|
|
||||||
expect(resolved).not.toBe(decoyPath);
|
|
||||||
} finally {
|
|
||||||
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,11 +53,8 @@ export const SCHEMA_FAIL_REMEDIATION = [
|
|||||||
*/
|
*/
|
||||||
export function resolveSchemaCheckConfigPath(explicit?: string): string | undefined {
|
export function resolveSchemaCheckConfigPath(explicit?: string): string | undefined {
|
||||||
if (explicit) return resolve(explicit);
|
if (explicit) return resolve(explicit);
|
||||||
// NOTE: no env-var candidate, deliberately. apps/gateway/src/env.ts gives env
|
|
||||||
// NO config authority (a stale MOSAIC_CONFIG could verify a database the
|
|
||||||
// daemon never reads — rev-code-02 review 285, note N1). Resolution order
|
|
||||||
// mirrors the daemon's file priorities only.
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
|
process.env['MOSAIC_CONFIG'],
|
||||||
join(homedir(), '.config', 'mosaic', 'gateway', 'mosaic.config.json'), // daemon-written
|
join(homedir(), '.config', 'mosaic', 'gateway', 'mosaic.config.json'), // daemon-written
|
||||||
resolve(process.cwd(), 'mosaic.config.json'),
|
resolve(process.cwd(), 'mosaic.config.json'),
|
||||||
join(homedir(), '.mosaic', 'mosaic.config.json'),
|
join(homedir(), '.mosaic', 'mosaic.config.json'),
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
|
|
||||||
vi.mock('../../auth.js', () => ({
|
|
||||||
loadSession: vi.fn(),
|
|
||||||
validateSession: vi.fn(),
|
|
||||||
signIn: vi.fn(),
|
|
||||||
saveSession: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./login.js', () => ({
|
|
||||||
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
|
|
||||||
promptLine: vi.fn(),
|
|
||||||
promptSecret: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./piped-credentials.js', () => ({
|
|
||||||
readCredentialsFromPipedStdin: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./daemon.js', () => ({
|
|
||||||
readMeta: vi.fn(),
|
|
||||||
writeMeta: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { ensureSession } from './token-ops.js';
|
|
||||||
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
|
|
||||||
import { promptLine, promptSecret } from './login.js';
|
|
||||||
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
|
|
||||||
|
|
||||||
const URL = 'http://localhost:14242';
|
|
||||||
|
|
||||||
function asNonTTY(): void {
|
|
||||||
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ensureSession — #1394 credential precedence (flag > piped stdin > prompt)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.mocked(loadSession).mockReturnValue(null);
|
|
||||||
asNonTTY();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stored valid session wins; no credentials touched', async () => {
|
|
||||||
vi.mocked(loadSession).mockReturnValue({ cookie: 'SESS', email: '[email protected]' } as never);
|
|
||||||
vi.mocked(validateSession).mockResolvedValue(true);
|
|
||||||
await expect(ensureSession(URL)).resolves.toBe('SESS');
|
|
||||||
expect(signIn).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flag email + stdin password: FLAG wins for email, stdin supplies the password', async () => {
|
|
||||||
vi.mocked(signIn).mockResolvedValue({ cookie: 'NEW', email: '[email protected]' } as never);
|
|
||||||
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
|
|
||||||
email: '[email protected]',
|
|
||||||
password: 'stdin-pw',
|
|
||||||
});
|
|
||||||
|
|
||||||
await ensureSession(URL, { email: '[email protected]' });
|
|
||||||
|
|
||||||
expect(signIn).toHaveBeenCalledWith(URL, '[email protected]', 'stdin-pw');
|
|
||||||
expect(promptLine).not.toHaveBeenCalled();
|
|
||||||
expect(promptSecret).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stdin-only path (no flag): both credentials from piped lines', async () => {
|
|
||||||
vi.mocked(signIn).mockResolvedValue({ cookie: 'NEW2', email: '[email protected]' } as never);
|
|
||||||
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
|
|
||||||
email: '[email protected]',
|
|
||||||
password: 'spw',
|
|
||||||
});
|
|
||||||
|
|
||||||
await ensureSession(URL);
|
|
||||||
expect(signIn).toHaveBeenCalledWith(URL, '[email protected]', 'spw');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('no credentials headless → exit(2) with --email guidance; signIn untouched', async () => {
|
|
||||||
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({ email: null, password: null });
|
|
||||||
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
||||||
throw new Error('EXIT');
|
|
||||||
}) as never);
|
|
||||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
await expect(ensureSession(URL)).rejects.toThrow('EXIT');
|
|
||||||
expect(exit).toHaveBeenCalledWith(2);
|
|
||||||
expect(err).toHaveBeenCalledWith(expect.stringContaining('--email'));
|
|
||||||
expect(signIn).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
exit.mockRestore();
|
|
||||||
err.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('successful sign-in persists the session', async () => {
|
|
||||||
vi.mocked(signIn).mockResolvedValue({ cookie: 'C', email: '[email protected]' } as never);
|
|
||||||
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
|
|
||||||
email: '[email protected]',
|
|
||||||
password: 'pw',
|
|
||||||
});
|
|
||||||
|
|
||||||
await ensureSession(URL);
|
|
||||||
expect(saveSession).toHaveBeenCalledWith(URL, expect.anything());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
|
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
|
||||||
import { readMeta, writeMeta } from './daemon.js';
|
import { readMeta, writeMeta } from './daemon.js';
|
||||||
import { getGatewayUrl, promptLine, promptSecret } from './login.js';
|
import { getGatewayUrl, promptLine, promptSecret } from './login.js';
|
||||||
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
|
|
||||||
|
|
||||||
interface MintedToken {
|
interface MintedToken {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -108,24 +107,8 @@ export async function requireSession(gatewayUrl: string): Promise<string> {
|
|||||||
* Ensure a valid session for the gateway, prompting for credentials if needed.
|
* Ensure a valid session for the gateway, prompting for credentials if needed.
|
||||||
* On sign-in failure, prints the error and exits non-zero.
|
* On sign-in failure, prints the error and exits non-zero.
|
||||||
* Returns the session cookie.
|
* Returns the session cookie.
|
||||||
*
|
|
||||||
* Credential precedence when sign-in is needed (#1394):
|
|
||||||
* 1. explicit opts (--email flag; highest)
|
|
||||||
* 2. non-TTY stdin — first line email, second line password (headless dual
|
|
||||||
* path for callers without argv access: printf 'email\npassword\n' | …)
|
|
||||||
* 3. interactive prompt (TTY only)
|
|
||||||
*/
|
*/
|
||||||
export interface SessionCredentialOptions {
|
export async function ensureSession(gatewayUrl: string): Promise<string> {
|
||||||
/** Email from an explicit flag (argv). Highest precedence. */
|
|
||||||
email?: string;
|
|
||||||
/** Password from an explicit source. Rare; passwords normally come via stdin/prompt. */
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureSession(
|
|
||||||
gatewayUrl: string,
|
|
||||||
opts: SessionCredentialOptions = {},
|
|
||||||
): Promise<string> {
|
|
||||||
// Try the stored session first
|
// Try the stored session first
|
||||||
const session = loadSession(gatewayUrl);
|
const session = loadSession(gatewayUrl);
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -136,25 +119,10 @@ export async function ensureSession(
|
|||||||
console.log(`No session found for ${gatewayUrl}. Please sign in.`);
|
console.log(`No session found for ${gatewayUrl}. Please sign in.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let email = opts.email;
|
// Prompt for credentials — password must not be echoed to the terminal
|
||||||
let password = opts.password;
|
const email = await promptLine('Email: ');
|
||||||
if ((!email || !password) && !process.stdin.isTTY) {
|
// Do not trim password — it may contain intentional leading/trailing whitespace
|
||||||
const piped = await readCredentialsFromPipedStdin();
|
const password = await promptSecret('Password: ');
|
||||||
email = email ?? piped.email ?? undefined;
|
|
||||||
password = password ?? piped.password ?? undefined;
|
|
||||||
}
|
|
||||||
if (!email || !password) {
|
|
||||||
if (!process.stdin.isTTY) {
|
|
||||||
console.error(
|
|
||||||
'No valid session and no credentials available headlessly. Provide --email plus ' +
|
|
||||||
"a password line on stdin (printf 'email\\npassword\\n' | …), or run interactively.",
|
|
||||||
);
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
email = await promptLine('Email: ');
|
|
||||||
// Do not trim password — it may contain intentional leading/trailing whitespace
|
|
||||||
password = await promptSecret('Password: ');
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
|
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
@@ -178,12 +146,11 @@ export async function runRotateToken(gatewayUrl?: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `mosaic gateway config recover-token` — signs in if no session exists.
|
* `mosaic gateway config recover-token` — prompts for login if no session exists.
|
||||||
* Passes the --email flag through to ensureSession (#1394 dual path).
|
|
||||||
*/
|
*/
|
||||||
export async function runRecoverToken(gatewayUrl?: string, email?: string): Promise<void> {
|
export async function runRecoverToken(gatewayUrl?: string): Promise<void> {
|
||||||
const url = getGatewayUrl(gatewayUrl);
|
const url = getGatewayUrl(gatewayUrl);
|
||||||
const cookie = await ensureSession(url, { email });
|
const cookie = await ensureSession(url);
|
||||||
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
|
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
|
||||||
const minted = await mintAdminToken(url, cookie, label);
|
const minted = await mintAdminToken(url, cookie, label);
|
||||||
persistToken(url, minted);
|
persistToken(url, minted);
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export async function runPostInstallVerification(
|
|||||||
const { runMigrations, getMigrationStatus } = await import('@mosaicstack/db');
|
const { runMigrations, getMigrationStatus } = await import('@mosaicstack/db');
|
||||||
const result = await checkDatabaseSchema(
|
const result = await checkDatabaseSchema(
|
||||||
{ runMigrations, getMigrationStatus },
|
{ runMigrations, getMigrationStatus },
|
||||||
undefined, // resolver mirrors daemon file priorities; env has no config authority (N1)
|
process.env['MOSAIC_CONFIG'],
|
||||||
);
|
);
|
||||||
if (result.status === 'ok') {
|
if (result.status === 'ok') {
|
||||||
ok(result.detail);
|
ok(result.detail);
|
||||||
|
|||||||
Reference in New Issue
Block a user