Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e06bf3f9a |
+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,328 +0,0 @@
|
|||||||
# Identity Account-Lifecycle Contract
|
|
||||||
|
|
||||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 4 of 9).
|
|
||||||
Authority: PRD D10 (better-auth is the account system of record), Q1 ruled O1
|
|
||||||
by Jason 2026-08-26 (webui-audit T10). This document turns that ruling into
|
|
||||||
enforceable policy. It also carries the bootstrap/first-admin invariant from
|
|
||||||
issue #1430, folded in here after PR #1431's independent review showed the
|
|
||||||
quick-fix approach was insufficient.
|
|
||||||
|
|
||||||
Revision 2: addresses the 9 findings of the independent review
|
|
||||||
(`fleet/lanes/webui-audit/findings/pr1433-review.md`) — epoch enforcement
|
|
||||||
tightened (§3), canonical email split from provider claims (§5), external
|
|
||||||
principal keyed by issuer+subject with DB uniqueness and link step-up (§6),
|
|
||||||
JIT default precedence and first-admin SSO path defined (§2, §4),
|
|
||||||
deactivation made measurable (§7.1), deletion kept in scope and the existing
|
|
||||||
hard-delete endpoint required to fail closed (§7.3), workspace identity
|
|
||||||
reconciled with the native-kanban SOT (§1.4), verification matrix expanded
|
|
||||||
(§8), factual labels corrected (§7.3, §8.1).
|
|
||||||
|
|
||||||
Revision 3: addresses the residuals and new findings of the revision-2
|
|
||||||
re-review (`fleet/lanes/webui-audit/findings/pr1433-review-r2.md`) —
|
|
||||||
`users.emailVerified` added to the canonical set with a defined reset rule on
|
|
||||||
email change (§5.1–5.2), external-principal uniqueness moved to
|
|
||||||
(issuer, subject) (§6.1), "can actually use" defined (§6.5), the shipped
|
|
||||||
delete affordances (web admin page, `mosaic auth users delete`) required to
|
|
||||||
be removed or disabled with a defined user-visible state (§7.3), the
|
|
||||||
admin-creation switch removed in favor of plain admin authorization (§2.3),
|
|
||||||
and §8 extended with observables for IdP removal, forward-auth non-use,
|
|
||||||
first-admin SSO, wizard-recorded JIT choice, the admin-guide statement, and
|
|
||||||
positive/expiry-bound step-up cases.
|
|
||||||
|
|
||||||
Scope: account creation, bootstrap, federated login, account linking, claim
|
|
||||||
mapping, deactivation, and (minimally) deletion gating. Out of scope: RBAC
|
|
||||||
grant semantics (contract 2), wizard UX flow (contract 3), hierarchy schema
|
|
||||||
(contract 1), sensitive-data custody (contract 7 / D14).
|
|
||||||
|
|
||||||
## 1. System of record
|
|
||||||
|
|
||||||
1. better-auth's tables (`users`, `accounts`, `sessions`, `verifications`) are
|
|
||||||
the only account system of record. All foreign keys reference `users.id`.
|
|
||||||
2. External IdPs (Authentik or any OIDC provider) are login methods, attached
|
|
||||||
through better-auth's generic-OAuth plugin (`packages/auth/src/sso.ts`).
|
|
||||||
They never own accounts. Removing an IdP removes a login method, not users.
|
|
||||||
3. The forward-auth perimeter shim is a deployment measure. Once in-app OIDC
|
|
||||||
is configured for a deployment, the shim is demoted: it may stay as network
|
|
||||||
perimeter, but no application code may read identity from its headers.
|
|
||||||
4. **Account ≠ workspace membership.** Creating an account (by any path:
|
|
||||||
bootstrap, sign-up, invite, JIT, admin creation) creates no workspace, no
|
|
||||||
hierarchy grant, and no workspace-scoped authority (native-kanban SOT
|
|
||||||
REQ-TEN-001 / REQ-ID-001). The better-auth `role` field is a platform/auth
|
|
||||||
role (`member` | `admin`), not workspace membership. Workspace grants are
|
|
||||||
defined by contract 2; until then a fresh account can authenticate and
|
|
||||||
holds no workspace authority.
|
|
||||||
|
|
||||||
## 2. Registration gating
|
|
||||||
|
|
||||||
Measured current state on `next`: `emailAndPassword.enabled: true` with no
|
|
||||||
gating — anyone who can reach the Gateway can create an account via
|
|
||||||
`POST /api/auth/sign-up/email` and receives role `member`.
|
|
||||||
|
|
||||||
Contract:
|
|
||||||
|
|
||||||
1. A single server-side setting `registration_mode` with values
|
|
||||||
`open | invite | closed`. It lives in the database (admin-mutable at
|
|
||||||
runtime), not in env config.
|
|
||||||
2. Default after bootstrap: `closed`. The wizard (contract 3) may set a
|
|
||||||
different mode during setup, recorded as an explicit operator choice.
|
|
||||||
While the bootstrap epoch is open (§3), the effective mode is `closed`
|
|
||||||
regardless of any stored value: the setting takes effect only after the
|
|
||||||
epoch completes.
|
|
||||||
3. `closed` blocks self-service email/password sign-up. It does not block
|
|
||||||
admin-created users or OIDC JIT (§4). Post-bootstrap admin creation is
|
|
||||||
gated by admin authorization alone — there is no separate switch for it.
|
|
||||||
JIT is gated by its per-provider flag (§4.1). All user-creating paths are
|
|
||||||
closed while the bootstrap epoch is open (§3).
|
|
||||||
4. `invite` requires a single-use, expiring invite token bound to an email
|
|
||||||
address. Invite issuance is an admin operation and is audit-logged.
|
|
||||||
5. Enforcement point: a better-auth hook (or equivalent middleware executed
|
|
||||||
inside the auth handler path), not a Gateway route guard in front of it —
|
|
||||||
the raw `/api/auth/*` handler must be incapable of bypassing the gate.
|
|
||||||
|
|
||||||
## 3. Bootstrap / first-admin invariant (from #1430)
|
|
||||||
|
|
||||||
Invariant: **the system transitions from zero users to one admin user exactly
|
|
||||||
once per bootstrap epoch, atomically, regardless of concurrency or which code
|
|
||||||
path writes users.**
|
|
||||||
|
|
||||||
Constraints any implementation MUST satisfy (each traces to a verified defect
|
|
||||||
in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`):
|
|
||||||
|
|
||||||
1. **Durable fail-closed epoch state, obeyed by every writer.** The epoch
|
|
||||||
lives in a constraint-backed one-row `bootstrap_state` table. While the
|
|
||||||
epoch is open, every non-bootstrap user-creating writer — better-auth
|
|
||||||
sign-up, OIDC JIT, admin creation — refuses, fail-closed, enforced inside
|
|
||||||
the writer's own path (better-auth hook for the raw handler; guard for
|
|
||||||
admin routes). A partial unique index or a winning epoch-transition row is
|
|
||||||
necessary but not sufficient on its own: neither stops an untagged insert
|
|
||||||
from a writer that never consulted the epoch. Both layers are required:
|
|
||||||
database-level transition safety (the epoch-completing write races safely
|
|
||||||
and at most one wins) and writer-level refusal (no path can create a user
|
|
||||||
without reading epoch state).
|
|
||||||
2. **Atomic first-admin transition.** The admin user, its credential account,
|
|
||||||
the initial admin token, and the epoch-completed transition commit in one
|
|
||||||
database transaction or not at all. A better-auth call through
|
|
||||||
`drizzleAdapter(db)` runs on the root pool and is NOT part of any caller
|
|
||||||
transaction; it may be used inside the bootstrap transition only if the
|
|
||||||
adapter is explicitly bound to the transaction handle. Otherwise the
|
|
||||||
bootstrap writer must create the user rows itself within the transaction.
|
|
||||||
3. **Pool safety.** No design may hold a pooled connection inside a
|
|
||||||
transaction while awaiting a write that acquires a second connection from
|
|
||||||
the same pool (`DB_POOL_MAX=1` is a supported configuration).
|
|
||||||
4. **Re-runnability (D4).** Bootstrap is not a one-shot: after the first-admin
|
|
||||||
epoch completes, re-running the wizard reconfigures the system but never
|
|
||||||
re-opens the zero-user transition. "Setup already completed" is a stable,
|
|
||||||
testable state, and factory-reset (a future, explicitly destructive
|
|
||||||
operation) is the only way to open a new epoch.
|
|
||||||
5. **No stranded partial outcome.** A failure at any point in the transition
|
|
||||||
leaves nothing observable (no admin user without its token, no completed
|
|
||||||
epoch without an admin) and setup remains retryable — this follows from
|
|
||||||
§3.2 and is stated separately because it is the pre-existing failure mode
|
|
||||||
the #1431 review verified.
|
|
||||||
6. **First-admin via SSO (D4).** When the operator chooses SSO for the
|
|
||||||
initial user, the wizard executes the OIDC login as part of the bootstrap
|
|
||||||
transition itself: the bootstrap writer creates the account from the
|
|
||||||
asserted identity inside the §3.2 transaction. This path is the bootstrap
|
|
||||||
writer, not JIT — §4's JIT gate stays closed during the epoch and is not
|
|
||||||
an obstacle to D4.
|
|
||||||
|
|
||||||
## 4. JIT provisioning (OIDC first login)
|
|
||||||
|
|
||||||
1. A successful OIDC login with no matching account creates a user
|
|
||||||
just-in-time only when `jit_provisioning` is enabled for that provider.
|
|
||||||
The flag is per-provider and defaults off, always. There is no
|
|
||||||
mode-implied default: Enterprise setup enables JIT only when the wizard
|
|
||||||
records it as an explicit operator choice for a named provider (this
|
|
||||||
replaces revision 1's "Enterprise mode defaults to closed with OIDC JIT
|
|
||||||
enabled", which contradicted the per-provider default).
|
|
||||||
2. JIT users receive platform role `member`, never an elevated role,
|
|
||||||
regardless of IdP claims (§5), and no workspace authority (§1.4).
|
|
||||||
3. An optional per-provider email-domain allowlist constrains JIT. The
|
|
||||||
allowlist matches only when the IdP asserts the email with
|
|
||||||
`email_verified: true`; an unverified address never satisfies the
|
|
||||||
allowlist. Empty allowlist with JIT on means any authenticated subject at
|
|
||||||
that IdP gets an account — permitted, but the wizard must present it as an
|
|
||||||
explicit choice.
|
|
||||||
4. JIT is disabled while the bootstrap epoch is open (§3.1). The first-admin
|
|
||||||
SSO path is §3.6, not JIT.
|
|
||||||
|
|
||||||
## 5. Claim mapping
|
|
||||||
|
|
||||||
1. **Two stores, not one.** Provider-observed claims (`email`,
|
|
||||||
`email_verified`, display name, avatar) are recorded per external
|
|
||||||
principal — keyed by issuer + subject (§6.1) — at first login and
|
|
||||||
refreshed at each login. The canonical account fields (`users.email`,
|
|
||||||
`users.emailVerified`, `users.name`, `users.image`) are set exactly once
|
|
||||||
at account creation and are never silently overwritten by a later login.
|
|
||||||
For SSO-created accounts (JIT or first-admin SSO), `users.emailVerified`
|
|
||||||
is set from the provider's `email_verified` claim at creation; for
|
|
||||||
password-created accounts it is false until the address completes
|
|
||||||
verification.
|
|
||||||
2. **Canonical email changes only through an explicit workflow.** Either the
|
|
||||||
user-initiated email change (with verification of the new address) or an
|
|
||||||
admin edit. Any canonical email change — user- or admin-initiated — sets
|
|
||||||
`users.emailVerified` to false until the new address completes
|
|
||||||
verification; an admin may instead explicitly attest the address as
|
|
||||||
verified in the same operation, and that attestation is audit-logged. A
|
|
||||||
provider-claim refresh never rebinds `users.email` or
|
|
||||||
`users.emailVerified`; a divergence between canonical email and the latest
|
|
||||||
provider-observed email is surfaced per §6.4.
|
|
||||||
3. Never mapped from IdP claims: `role` and any future authorization
|
|
||||||
attribute. Authorization lives in the system of record and in the RBAC
|
|
||||||
layer (contract 2). An IdP group/role claim may at most be recorded for
|
|
||||||
audit; it grants nothing.
|
|
||||||
|
|
||||||
## 6. Account linking trust
|
|
||||||
|
|
||||||
1. **External principal identity is issuer + subject.** A linked identity is
|
|
||||||
keyed by the OIDC issuer and subject claims, not by an unqualified
|
|
||||||
provider subject id and not by email. The linked-identity row stores the
|
|
||||||
issuer, and the database enforces at most one local account per
|
|
||||||
**(issuer, subject)** with a unique constraint on those stored columns —
|
|
||||||
uniqueness on (provider, subject) is insufficient because provider →
|
|
||||||
issuer is not one-to-one: two provider configurations can point at the
|
|
||||||
same issuer, and the identity must not alias across them. The current
|
|
||||||
non-unique `(provider_id, account_id)` index satisfies neither;
|
|
||||||
application-level checks without a uniqueness witness lose
|
|
||||||
concurrent-callback races. Each configured provider additionally binds to
|
|
||||||
exactly one issuer, immutable after creation (changing the issuer means
|
|
||||||
creating a new provider).
|
|
||||||
2. Linking an OIDC identity to an existing account happens only in one of two
|
|
||||||
ways: (a) explicit link initiated by the logged-in user from settings,
|
|
||||||
which requires step-up: a fresh reauthentication (password or existing
|
|
||||||
linked method) no older than a short bound the implementation defines
|
|
||||||
(≤ 10 minutes) — a session cookie alone is insufficient, so a stolen
|
|
||||||
session cannot quietly attach a durable login method; or (b) automatic
|
|
||||||
link when the IdP asserts a verified email exactly matching an existing
|
|
||||||
account **and** the provider is marked `trusted_for_linking`
|
|
||||||
(per-provider flag, default off).
|
|
||||||
3. Untrusted-provider email collision produces a login error naming the
|
|
||||||
conflict, not an auto-link and not a duplicate account.
|
|
||||||
4. A linked identity whose IdP-observed email later diverges from the
|
|
||||||
canonical account email keeps working (the link is by issuer + subject,
|
|
||||||
§6.1) but the divergence is surfaced in the user's settings and audit log
|
|
||||||
(the per-principal claim store in §5.1 is what makes the divergence
|
|
||||||
representable).
|
|
||||||
5. Unlinking a login method is refused when it would leave the account with
|
|
||||||
no **usable** login method. Usable means: a set password, or a linked
|
|
||||||
identity whose provider is currently configured and enabled on this
|
|
||||||
deployment. A linked identity whose provider has been removed or disabled
|
|
||||||
(§1.2) is not usable and does not count; setting a password first lifts
|
|
||||||
the refusal.
|
|
||||||
|
|
||||||
## 7. Deactivation propagation
|
|
||||||
|
|
||||||
1. **Deactivation (better-auth admin ban) is authoritative and bounded.**
|
|
||||||
Concretely:
|
|
||||||
- Ban and session revocation are one operation: the ban commit revokes all
|
|
||||||
better-auth sessions for the user. If revocation partially fails, the
|
|
||||||
ban itself must already be committed and every guard denies from that
|
|
||||||
point (fail closed); the operation is retryable.
|
|
||||||
- Every authenticated entry path checks banned state: HTTP session guards,
|
|
||||||
the admin bearer-token path (which today does not test `banned` — an
|
|
||||||
implementation defect this contract makes non-conformant), MCP, and
|
|
||||||
Socket.IO.
|
|
||||||
- Active socket connections are terminated or denied within 30 seconds of
|
|
||||||
the ban commit, or at the next inbound message on that socket, whichever
|
|
||||||
comes first (socket auth at connect-time only, as today, does not
|
|
||||||
satisfy this).
|
|
||||||
- The current admin ban route updates only the user row; it does not
|
|
||||||
conform to this section until revocation and guard coverage land.
|
|
||||||
- Admin tokens owned by the banned user are revoked in the same operation.
|
|
||||||
2. Deactivation at an external IdP does not propagate automatically in this
|
|
||||||
contract's scope (no SCIM). Operational rule: removing a user from the IdP
|
|
||||||
without banning them in Mosaic leaves any password or other linked login
|
|
||||||
method usable — the admin guide must state this. SCIM/webhook-driven
|
|
||||||
propagation is future work and out of scope here.
|
|
||||||
3. **Deletion is not deactivation, and deletion is gated here.** Account
|
|
||||||
deletion semantics (FK fan-out across the 21 foreign-key constraints to
|
|
||||||
`users.id`, spread over 19 referencing tables) require their own
|
|
||||||
deletion-and-retention contract, chartered as an addition to the S2 list —
|
|
||||||
contract 7 is the D14 sensitive-data custody contract and does not cover
|
|
||||||
account deletion. Until that deletion contract is ratified: the existing
|
|
||||||
hard-delete endpoint (`DELETE /api/admin/users/:id`) is disabled and fails
|
|
||||||
closed, and deactivation is the only supported removal operation. A
|
|
||||||
contract that merely declared deactivation "the only supported removal"
|
|
||||||
while the endpoint stayed live would be false on its face.
|
|
||||||
Disabling the endpoint alone is insufficient — its shipped callers must
|
|
||||||
not be left as advertised operations that now fail generically:
|
|
||||||
- The admin web UI delete action (`apps/web/src/app/(dashboard)/admin/page.tsx`
|
|
||||||
and any SPA port of it) is removed, or replaced by a disabled control
|
|
||||||
whose visible text states that deletion is unavailable pending the
|
|
||||||
deletion-and-retention contract and points at deactivation.
|
|
||||||
- The CLI command `mosaic auth users delete`
|
|
||||||
(`packages/mosaic/src/commands/auth.ts`) is removed, or exits non-zero
|
|
||||||
with a message stating the same and naming the deactivation command.
|
|
||||||
- Both surfaces expose deactivation as the supported operation.
|
|
||||||
|
|
||||||
## 8. Verification requirements
|
|
||||||
|
|
||||||
Every MUST above needs a bounded observable. The matrix:
|
|
||||||
|
|
||||||
1. **Bootstrap invariant (§3).** Real-PostgreSQL concurrency tests using two
|
|
||||||
distinct physical connections (pattern:
|
|
||||||
`apps/gateway/src/agent/connector-lease.postgres.integration.test.ts`,
|
|
||||||
which runs in the `test` CI step against the `ci-postgres` PostgreSQL
|
|
||||||
service — note that pattern multiplexes one pooled handle, so the tests
|
|
||||||
here must explicitly open separate connections). Races to cover:
|
|
||||||
setup-vs-setup, setup-vs-raw-sign-up, setup-vs-JIT, setup-vs-admin-create.
|
|
||||||
Plus: liveness under `DB_POOL_MAX=1`; fault injection after each write in
|
|
||||||
the transition (user, credential, token, epoch) proving nothing observable
|
|
||||||
leaks and setup retries; wizard re-run after completion proving the
|
|
||||||
zero-user transition never re-opens. Mocked-transaction specs are
|
|
||||||
supplementary; they cannot prove serialization.
|
|
||||||
2. **Registration gating (§2).** Spec coverage of all three modes against the
|
|
||||||
raw `/api/auth/` handler path, not only Gateway controllers; invite
|
|
||||||
lifecycle (single-use, expiry, email binding); effective-`closed` while
|
|
||||||
the epoch is open regardless of stored mode.
|
|
||||||
3. **JIT (§4).** Provider flag off → no account on first OIDC login; on →
|
|
||||||
account with platform role `member` and no workspace grant; domain
|
|
||||||
allowlist rejects an unverified email claim even when the domain matches;
|
|
||||||
JIT refused while the epoch is open.
|
|
||||||
4. **Claim mapping (§5).** Login refresh updates the per-principal claim
|
|
||||||
store and touches none of the canonical fields (`users.email`,
|
|
||||||
`users.emailVerified`, name, image); explicit email-change workflow is the
|
|
||||||
only path that rebinds canonical email; every canonical email change
|
|
||||||
resets `users.emailVerified` to false unless the admin attestation path
|
|
||||||
is taken, and that attestation appears in the audit log.
|
|
||||||
5. **Linking (§6).** Unique-constraint witness: concurrent first-login
|
|
||||||
callbacks for the same (issuer, subject) yield exactly one account, and
|
|
||||||
two provider configurations sharing one issuer cannot create two accounts
|
|
||||||
for the same subject; trusted auto-link; untrusted collision error;
|
|
||||||
step-up both ways: an explicit link succeeds immediately after a fresh
|
|
||||||
reauthentication and is refused once the implementation's chosen bound
|
|
||||||
(≤ 10 minutes) has elapsed, and refused with no reauthentication at all;
|
|
||||||
unlink refusal when no remaining method is usable per §6.5, including the
|
|
||||||
removed-provider case, and acceptance after a password is set; divergence
|
|
||||||
surfaced after IdP email change.
|
|
||||||
6. **Deactivation (§7).** Ban revokes sessions atomically or fails closed
|
|
||||||
(partial-failure injection); guard denial post-ban on each transport:
|
|
||||||
HTTP session, admin bearer token, MCP, Socket.IO; active socket terminated
|
|
||||||
within the 30-second/next-message bound; banned user's admin tokens
|
|
||||||
unusable; hard-delete endpoint returns a fail-closed error while the
|
|
||||||
deletion contract is unratified; the admin web UI renders no live delete
|
|
||||||
action (absent, or disabled with the §7.3 text) and `mosaic auth users
|
|
||||||
delete` exits non-zero with the §7.3 message — both asserted by spec.
|
|
||||||
7. **System of record and bootstrap edges (§1, §3.6, §4.3).** IdP removal:
|
|
||||||
deleting a provider configuration leaves every user row intact and every
|
|
||||||
other login method working (spec over the provider-config removal path).
|
|
||||||
Forward-auth non-use: with in-app OIDC configured, a request carrying
|
|
||||||
forward-auth identity headers and no session is treated as anonymous —
|
|
||||||
no code path derives identity from those headers (negative spec at the
|
|
||||||
Gateway entry). First-admin SSO: the §3.6 transition commits account,
|
|
||||||
token, and epoch atomically from the asserted identity, and fault
|
|
||||||
injection mid-transition leaves nothing observable (same harness as §8.1).
|
|
||||||
Wizard-recorded JIT choice: enabling JIT for a provider writes an
|
|
||||||
explicit per-provider operator-choice record, and no mode selection
|
|
||||||
enables it implicitly (assert the stored record, not UI behavior).
|
|
||||||
8. **Documentation observable (§7.2).** The admin guide contains the
|
|
||||||
IdP-removal-does-not-deactivate statement; verified by a docs assertion
|
|
||||||
(content check in CI or an enumerated review-checklist item on the
|
|
||||||
implementing PR) — a MUST about documentation needs a checkable artifact,
|
|
||||||
not intent.
|
|
||||||
|
|
||||||
## Ruling request
|
|
||||||
|
|
||||||
Ratify sections 1–8 as written, with one decision embedded: registration
|
|
||||||
defaults to `closed` after bootstrap (§2.2) — say "agreed" or name the mode
|
|
||||||
you want as the default.
|
|
||||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||||
@@ -203,14 +202,9 @@ export function registerGatewayCommand(program: Command): void {
|
|||||||
|
|
||||||
gw.command('uninstall')
|
gw.command('uninstall')
|
||||||
.description('Uninstall the gateway daemon and optionally remove data')
|
.description('Uninstall the gateway daemon and optionally remove data')
|
||||||
.option(
|
.action(async () => {
|
||||||
'-y, --yes',
|
|
||||||
'Headless: skip the confirmation prompt (required when stdin is not a TTY)',
|
|
||||||
)
|
|
||||||
.option('--remove-data', 'Also remove all gateway data (never implied by --yes)')
|
|
||||||
.action(async (cmdOpts: { yes?: boolean; removeData?: boolean }) => {
|
|
||||||
const { runUninstall } = await import('./gateway/uninstall.js');
|
const { runUninstall } = await import('./gateway/uninstall.js');
|
||||||
await runUninstall(cmdOpts);
|
await runUninstall();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── doctor ─────────────────────────────────────────────────────────────────
|
// ─── doctor ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { mkdirSync } from 'node:fs';
|
|
||||||
|
|
||||||
vi.mock('./daemon.js', () => ({
|
|
||||||
GATEWAY_HOME: '/tmp/u-test-gateway-home',
|
|
||||||
getDaemonPid: vi.fn().mockReturnValue(null),
|
|
||||||
readMeta: vi.fn(),
|
|
||||||
stopDaemon: vi.fn(),
|
|
||||||
uninstallGatewayPackage: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { runUninstall } from './uninstall.js';
|
|
||||||
import { readMeta, uninstallGatewayPackage } from './daemon.js';
|
|
||||||
|
|
||||||
describe('gateway uninstall — #1390 headless semantics', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('non-TTY without --yes FAILS LOUD (exit 1, nothing touched)', async () => {
|
|
||||||
vi.mocked(readMeta).mockReturnValue({
|
|
||||||
version: '0.0.7',
|
|
||||||
installedAt: '',
|
|
||||||
entryPoint: '',
|
|
||||||
host: 'localhost',
|
|
||||||
port: 14242,
|
|
||||||
});
|
|
||||||
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
||||||
throw new Error('EXIT');
|
|
||||||
}) as never);
|
|
||||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
await expect(runUninstall()).rejects.toThrow('EXIT');
|
|
||||||
expect(exit).toHaveBeenCalledWith(1);
|
|
||||||
expect(err).toHaveBeenCalledWith(expect.stringContaining('stdin is not a TTY'));
|
|
||||||
expect(uninstallGatewayPackage).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
exit.mockRestore();
|
|
||||||
err.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('--yes proceeds headlessly WITHOUT removing data (never implied)', async () => {
|
|
||||||
const meta = {
|
|
||||||
version: '0.0.7',
|
|
||||||
installedAt: '',
|
|
||||||
entryPoint: '',
|
|
||||||
host: 'localhost',
|
|
||||||
port: 14242,
|
|
||||||
};
|
|
||||||
vi.mocked(readMeta).mockReturnValue(meta);
|
|
||||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
|
|
||||||
await runUninstall({ yes: true });
|
|
||||||
|
|
||||||
expect(uninstallGatewayPackage).toHaveBeenCalledTimes(1);
|
|
||||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('Gateway data kept'));
|
|
||||||
log.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('--yes --remove-data removes data headlessly', async () => {
|
|
||||||
vi.mocked(readMeta).mockReturnValue({
|
|
||||||
version: '0.0.7',
|
|
||||||
installedAt: '',
|
|
||||||
entryPoint: '',
|
|
||||||
host: 'localhost',
|
|
||||||
port: 14242,
|
|
||||||
});
|
|
||||||
mkdirSync('/tmp/u-test-gateway-home', { recursive: true }); // existsSync gate
|
|
||||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
|
|
||||||
await runUninstall({ yes: true, removeData: true });
|
|
||||||
|
|
||||||
expect(uninstallGatewayPackage).toHaveBeenCalledTimes(1);
|
|
||||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('Gateway data removed'));
|
|
||||||
log.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('no meta → clean no-op even with --yes', async () => {
|
|
||||||
vi.mocked(readMeta).mockReturnValue(null);
|
|
||||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
await runUninstall({ yes: true });
|
|
||||||
expect(log).toHaveBeenCalledWith('Gateway is not installed.');
|
|
||||||
expect(uninstallGatewayPackage).not.toHaveBeenCalled();
|
|
||||||
log.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -8,65 +8,30 @@ import {
|
|||||||
uninstallGatewayPackage,
|
uninstallGatewayPackage,
|
||||||
} from './daemon.js';
|
} from './daemon.js';
|
||||||
|
|
||||||
export interface UninstallOptions {
|
export async function runUninstall(): Promise<void> {
|
||||||
/** Skip the confirmation prompt (headless/scripted uninstall). */
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
yes?: boolean;
|
|
||||||
/** Also remove all gateway data at GATEWAY_HOME (never implied by --yes). */
|
|
||||||
removeData?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runUninstall(opts: UninstallOptions = {}): Promise<void> {
|
|
||||||
const nonInteractive = Boolean(opts.yes) || process.env['MOSAIC_ASSUME_YES'] === '1';
|
|
||||||
|
|
||||||
// Non-TTY without explicit consent must FAIL LOUD, not quietly do nothing:
|
|
||||||
// the pre-fix behavior (prompt on a closed stdin → default No → exit 0,
|
|
||||||
// gateway untouched) reported success-by-silence to every scripted caller
|
|
||||||
// (#1390). An explicit refusal beats a silent no-op.
|
|
||||||
if (!nonInteractive && !process.stdin.isTTY) {
|
|
||||||
console.error(
|
|
||||||
'gateway uninstall: stdin is not a TTY and no --yes was given — refusing to ' +
|
|
||||||
'run an interactive uninstall headlessly (nothing was changed). ' +
|
|
||||||
'Use --yes (and --remove-data to also delete gateway data), or run from a terminal.',
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rl = nonInteractive
|
|
||||||
? null
|
|
||||||
: createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
try {
|
try {
|
||||||
await doUninstall(rl as NonNullable<typeof rl>, opts, nonInteractive);
|
await doUninstall(rl);
|
||||||
} finally {
|
} finally {
|
||||||
rl?.close();
|
rl.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function prompt(
|
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||||
rl: NonNullable<ReturnType<typeof createInterface>>,
|
|
||||||
question: string,
|
|
||||||
): Promise<string> {
|
|
||||||
return new Promise((resolve) => rl.question(question, resolve));
|
return new Promise((resolve) => rl.question(question, resolve));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doUninstall(
|
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||||
rl: ReturnType<typeof createInterface>,
|
|
||||||
opts: UninstallOptions,
|
|
||||||
nonInteractive: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
const meta = readMeta();
|
const meta = readMeta();
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
console.log('Gateway is not installed.');
|
console.log('Gateway is not installed.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nonInteractive) {
|
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
|
||||||
console.log(`Uninstalling Mosaic Gateway (--yes${opts.removeData ? ' --remove-data' : ''})...`);
|
if (answer.toLowerCase() !== 'y') {
|
||||||
} else {
|
console.log('Aborted.');
|
||||||
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
|
return;
|
||||||
if (answer.toLowerCase() !== 'y') {
|
|
||||||
console.log('Aborted.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop if running
|
// Stop if running
|
||||||
@@ -80,20 +45,13 @@ async function doUninstall(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove config/data. Interactive: ask. Headless: only with the explicit
|
// Remove config/data
|
||||||
// flag — destructive recursion is never implied by --yes alone (#1390).
|
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
|
||||||
let removeData = Boolean(opts.removeData);
|
if (removeData.toLowerCase() === 'y') {
|
||||||
if (!nonInteractive) {
|
|
||||||
const answer = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
|
|
||||||
removeData = answer.toLowerCase() === 'y';
|
|
||||||
}
|
|
||||||
if (removeData) {
|
|
||||||
if (existsSync(GATEWAY_HOME)) {
|
if (existsSync(GATEWAY_HOME)) {
|
||||||
rmSync(GATEWAY_HOME, { recursive: true, force: true });
|
rmSync(GATEWAY_HOME, { recursive: true, force: true });
|
||||||
console.log('Gateway data removed.');
|
console.log('Gateway data removed.');
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log(`Gateway data kept at ${GATEWAY_HOME}.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uninstall npm package
|
// Uninstall npm package
|
||||||
|
|||||||
@@ -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