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,279 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -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) {
|
|
||||||
const piped = await readCredentialsFromPipedStdin();
|
|
||||||
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
|
// Do not trim password — it may contain intentional leading/trailing whitespace
|
||||||
password = await promptSecret('Password: ');
|
const 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