Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
339e147f77 | ||
|
|
e595855adc | ||
|
|
f507b2bc79 | ||
|
|
4d3de6c58c | ||
|
|
49b7943420 | ||
|
|
19e16bd44f | ||
|
|
3bd490c080 | ||
|
|
4b448109dd | ||
|
|
bc1149c15e | ||
|
|
089953a7cf | ||
|
|
7b25be22e9 | ||
|
|
4e3d179e61 | ||
|
|
ae58482b72 | ||
|
|
b2d40dada0 | ||
|
|
d30a4cce00 |
+6
-4
@@ -38,10 +38,12 @@ when:
|
||||
- event: push
|
||||
branch: main
|
||||
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||
# This avoids from_secret which is blocked on pull_request events.
|
||||
# If the env vars aren't set, turbo falls back to local cache only.
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is wired in publish.yml via the
|
||||
# org-level Woodpecker secret `turbo_token` (events: push/tag/cron/manual/
|
||||
# deployment — never pull_request). This PR pipeline deliberately gets no
|
||||
# remote-cache credentials: an untrusted PR must not be able to write to (or
|
||||
# poison) the shared cache. Without TURBO_* env vars turbo falls back to
|
||||
# local cache only, which is the intended behavior here.
|
||||
|
||||
steps:
|
||||
install:
|
||||
|
||||
+41
-14
@@ -32,6 +32,11 @@ variables:
|
||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||
# the separate `event: tag` entry.)
|
||||
# #1407: ONE shared anchor for all three image steps. A second main-only
|
||||
# anchor previously gated build-web/build-appservice, so next-lane pushes
|
||||
# published gateway sha images with no web/appservice counterpart — no
|
||||
# sha-parity set existed for next-lane containerized deploys. Every image
|
||||
# step now builds on next too (sha-only destinations, enforced per step).
|
||||
- &image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
@@ -44,16 +49,6 @@ variables:
|
||||
- '.woodpecker/**'
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
- &main_image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main, next]
|
||||
@@ -73,6 +68,13 @@ steps:
|
||||
# being empty) and on any incomplete verification.
|
||||
verify:
|
||||
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:
|
||||
- *enable_pnpm
|
||||
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
||||
@@ -108,6 +110,13 @@ steps:
|
||||
|
||||
build:
|
||||
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:
|
||||
- *enable_pnpm
|
||||
- pnpm build
|
||||
@@ -460,7 +469,7 @@ steps:
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
@@ -474,8 +483,17 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next appservice publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||
@@ -495,7 +513,7 @@ steps:
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
@@ -509,8 +527,17 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next web publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||
|
||||
@@ -14,10 +14,16 @@ import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||
import { McpService } from './mcp/mcp.service.js';
|
||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||
import { resolveGatewayConfigPath } from './env.js';
|
||||
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
// Fail loud BEFORE anything else if the global ValidationPipe cannot see
|
||||
// the guarded DTOs' decorated properties (#1391): a broken metatype turns
|
||||
// every request body into a 400 at first use; this surfaces it at boot.
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
|
||||
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||
throw new Error('BETTER_AUTH_SECRET is required');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Boot-time ValidationPipe metatype self-check (#1391).
|
||||
*
|
||||
* The check exists to fail loud at boot when the global pipe cannot see a
|
||||
* guarded DTO's decorated properties — the #436 class-erasure signature and
|
||||
* its dependency-graph cousins. Red/green arms:
|
||||
*
|
||||
* GREEN real module state: BootstrapSetupDto's three properties are
|
||||
* decorated and visible through the globalThis-shared storage.
|
||||
* RED a control class with NO decorators (the erasure shape): the
|
||||
* check throws PipeMetatypeCheckError naming every property.
|
||||
* RED-2 a control where one property is decorated and two are not: the
|
||||
* error names exactly the missing two — the miss list is precise,
|
||||
* not a blanket failure.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IsString } from 'class-validator';
|
||||
import {
|
||||
assertValidationPipeSeesDtoDecorators,
|
||||
PipeMetatypeCheckError,
|
||||
} from './validation-pipe-check.js';
|
||||
|
||||
describe('assertValidationPipeSeesDtoDecorators (#1391 boot check)', () => {
|
||||
it('GREEN: passes on real module state (decorated DTO visible to the pipe)', () => {
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
||||
});
|
||||
|
||||
it('RED control: a class whose properties lost their decorators throws, naming them', async () => {
|
||||
// Simulate metatype erasure: an undecorated class standing where a
|
||||
// decorated DTO should be. Redefine the guard table for the test by
|
||||
// importing the module and pointing its table at the eroded class —
|
||||
// the check reads the table at call time, so a fresh module instance
|
||||
// with a swapped table reproduces the boot failure deterministically.
|
||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
||||
|
||||
class ErodedDto {
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const original = PIPE_GUARDED_DTOS[0];
|
||||
expect(original).toBeDefined();
|
||||
// Swap in the eroded target (same declared properties, zero decorators).
|
||||
(
|
||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
||||
name: 'ErodedDto',
|
||||
target: ErodedDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).toThrow(PipeMetatypeCheckError);
|
||||
try {
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
expect(message).toContain('ErodedDto.name');
|
||||
expect(message).toContain('ErodedDto.email');
|
||||
expect(message).toContain('ErodedDto.password');
|
||||
}
|
||||
} finally {
|
||||
// Restore real module state for any later test in this file.
|
||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
||||
}
|
||||
// And confirm the restore is real.
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
||||
});
|
||||
|
||||
it('RED-2 control: a partially decorated class names exactly the missing properties', async () => {
|
||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
||||
|
||||
class HalfErodedDto {
|
||||
@IsString()
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const original = PIPE_GUARDED_DTOS[0];
|
||||
(
|
||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
||||
name: 'HalfErodedDto',
|
||||
target: HalfErodedDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
});
|
||||
|
||||
try {
|
||||
try {
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
expect.unreachable('partially decorated DTO must fail the boot check');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
expect(message).toContain('HalfErodedDto.email');
|
||||
expect(message).toContain('HalfErodedDto.password');
|
||||
expect(message).not.toContain('HalfErodedDto.name has no');
|
||||
}
|
||||
} finally {
|
||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataStorage } from 'class-validator';
|
||||
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
|
||||
|
||||
/**
|
||||
* Boot-time self-check: the global ValidationPipe must be able to SEE the
|
||||
* decorated properties of the DTOs it guards (#1391, #436 class).
|
||||
*
|
||||
* WHY THIS EXISTS. When Nest resolves a @Body() metatype to Object — via
|
||||
* `import type` class erasure (#436), or a dependency graph where the
|
||||
* controller's decorators and the application's route enhancers disagree
|
||||
* (#1391's hypothesized dual-@nestjs/common on a mixed install) — the
|
||||
* ValidationPipe's whitelist treats every property as forbidden. The first
|
||||
* symptom is a 400 on the FIRST bootstrap attempt of a fresh install, the
|
||||
* worst place to discover wiring damage: the operator cannot tell a broken
|
||||
* payload from a broken daemon.
|
||||
*
|
||||
* This check fails LOUD at boot instead: if the pipe cannot see the DTO's
|
||||
* decorated properties, the gateway refuses to start with a named cause.
|
||||
* It catches the whole class — erasure, decorator metadata loss — on every
|
||||
* host, at the moment the damage exists rather than at first use.
|
||||
*
|
||||
* Storage sharing note: class-validator keys its metadata storage on
|
||||
* globalThis, so duplicate package copies do NOT hide metadata (measured,
|
||||
* #1391 diagnosis). What hides it is losing the metatype itself, which is
|
||||
* what this asserts against.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DTOs the global pipe guards, mapped to the properties the whitelist must
|
||||
* admit. Target is the CONSTRUCTOR (the object class itself): class-validator
|
||||
* decorators register metadata keyed on the constructor, and its executor
|
||||
* looks up `object.constructor` (ValidationExecutor.js:50) — the probe
|
||||
* through `prototype` returns zero. Extend when adding DTOs to the app.
|
||||
*/
|
||||
export const PIPE_GUARDED_DTOS: Array<{
|
||||
name: string;
|
||||
target: abstract new (...args: never[]) => unknown;
|
||||
properties: string[];
|
||||
}> = [
|
||||
{
|
||||
name: 'BootstrapSetupDto',
|
||||
target: BootstrapSetupDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
},
|
||||
];
|
||||
|
||||
export class PipeMetatypeCheckError extends Error {
|
||||
constructor(missing: string[]) {
|
||||
super(
|
||||
'ValidationPipe metatype check failed: ' +
|
||||
missing.join('; ') +
|
||||
'. The global ValidationPipe cannot see decorated DTO properties — ' +
|
||||
'every request body would be rejected as non-whitelisted. ' +
|
||||
'Check for import-type erasure or decorator metadata loss in the ' +
|
||||
'dependency graph (see issues #436, #1391).',
|
||||
);
|
||||
this.name = 'PipeMetatypeCheckError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the pipe's whitelist can see every guarded DTO's decorated
|
||||
* properties. Throws PipeMetatypeCheckError (fail-loud at boot) listing
|
||||
* each miss. Pure function of module state: no I/O, safe to call twice.
|
||||
*/
|
||||
export function assertValidationPipeSeesDtoDecorators(): void {
|
||||
const storage = getMetadataStorage();
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const dto of PIPE_GUARDED_DTOS) {
|
||||
// class-validator records constraints keyed on the DTO's constructor
|
||||
// (decorators run on the class), and its executor resolves them via
|
||||
// object.constructor. A property with no recorded metadata is invisible
|
||||
// to the whitelist — whatever the cause — and fails here.
|
||||
// Signature mirrors ValidationExecutor.js:50 — (constructor, schema, always,
|
||||
// strictGroups, groups?). No schema, always=true, no groups: every
|
||||
// constraint regardless of grouping, which is what the whitelist sees.
|
||||
const metadatas = storage.getTargetValidationMetadatas(dto.target, '', true, false);
|
||||
const decorated = new Set(metadatas.map((m) => m.propertyName));
|
||||
|
||||
for (const property of dto.properties) {
|
||||
if (!decorated.has(property)) {
|
||||
missing.push(
|
||||
`${dto.name}.${property} has no class-validator constraints visible to the pipe`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new PipeMetatypeCheckError(missing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'reflect-metadata';
|
||||
import { type CanActivate, type ExecutionContext, type INestApplication } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { TeamsController } from './teams.controller.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
const teamAlpha = { id: 'team-alpha', name: 'Alpha' };
|
||||
const teamBeta = { id: 'team-beta', name: 'Beta' };
|
||||
|
||||
// user-1 is a member of team-alpha only; admin-1 has role admin.
|
||||
let currentUser: { id: string; role?: string } = { id: 'user-1' };
|
||||
|
||||
const teamsServiceMock = {
|
||||
findAll: vi.fn(() => Promise.resolve([teamAlpha, teamBeta])),
|
||||
findAllForUser: vi.fn((userId: string) =>
|
||||
Promise.resolve(userId === 'user-1' ? [teamAlpha] : []),
|
||||
),
|
||||
findById: vi.fn((id: string) => Promise.resolve([teamAlpha, teamBeta].find((t) => t.id === id))),
|
||||
listMembers: vi.fn(() => Promise.resolve([{ teamId: 'team-alpha', userId: 'user-1' }])),
|
||||
isMember: vi.fn((teamId: string, userId: string) =>
|
||||
Promise.resolve(teamId === 'team-alpha' && userId === 'user-1'),
|
||||
),
|
||||
};
|
||||
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requestContext = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: { id: string; role?: string } }>();
|
||||
requestContext.user = currentUser;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
describe('teams endpoints are scoped to membership', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [TeamsController],
|
||||
providers: [{ provide: TeamsService, useValue: teamsServiceMock }],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(authGuard)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
currentUser = { id: 'user-1' };
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns only the teams the user belongs to', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha]);
|
||||
expect(teamsServiceMock.findAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns every team for an admin', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha, teamBeta]);
|
||||
expect(teamsServiceMock.findAllForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 403 for a non-member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 404 for a missing team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-missing');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns the team for a member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(teamAlpha);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members returns 403 for a non-member and members for a member', async () => {
|
||||
const denied = await request(app.getHttpServer()).get('/api/teams/team-beta/members');
|
||||
expect(denied.status).toBe(403);
|
||||
expect(teamsServiceMock.listMembers).not.toHaveBeenCalled();
|
||||
|
||||
const allowed = await request(app.getHttpServer()).get('/api/teams/team-alpha/members');
|
||||
expect(allowed.status).toBe(200);
|
||||
expect(allowed.body).toEqual([{ teamId: 'team-alpha', userId: 'user-1' }]);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId allows a self-lookup on any team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: false });
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId denies looking up another user on a foreign team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-2');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('an admin can look up any membership', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: true });
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,68 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
type RequestUser = { id: string; role?: string };
|
||||
|
||||
@Controller('api/teams')
|
||||
@UseGuards(AuthGuard)
|
||||
export class TeamsController {
|
||||
constructor(private readonly teams: TeamsService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.teams.findAll();
|
||||
async list(@CurrentUser() user: RequestUser) {
|
||||
if (user.role === 'admin') {
|
||||
return this.teams.findAll();
|
||||
}
|
||||
return this.teams.findAllForUser(user.id);
|
||||
}
|
||||
|
||||
@Get(':teamId')
|
||||
async findOne(@Param('teamId') teamId: string) {
|
||||
return this.teams.findById(teamId);
|
||||
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
return this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
|
||||
@Get(':teamId/members')
|
||||
async listMembers(@Param('teamId') teamId: string) {
|
||||
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
return this.teams.listMembers(teamId);
|
||||
}
|
||||
|
||||
@Get(':teamId/members/:userId')
|
||||
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||
async checkMembership(
|
||||
@Param('teamId') teamId: string,
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() user: RequestUser,
|
||||
) {
|
||||
// A user may always ask about their own membership; anything else is
|
||||
// team-scoped like the other routes.
|
||||
if (userId !== user.id) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
const isMember = await this.teams.isMember(teamId, userId);
|
||||
return { isMember };
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-scoped access: admins see any team; everyone else only teams they
|
||||
* are a member of. NotFoundException when the team does not exist and
|
||||
* ForbiddenException when the user lacks access (same convention as the
|
||||
* projects controller).
|
||||
*/
|
||||
private async getAccessibleTeam(teamId: string, user: RequestUser) {
|
||||
const team = await this.teams.findById(teamId);
|
||||
if (!team) throw new NotFoundException('Team not found');
|
||||
if (user.role === 'admin') return team;
|
||||
const isMember = await this.teams.isMember(teamId, user.id);
|
||||
if (!isMember) throw new ForbiddenException('Not a member of this team');
|
||||
return team;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -56,6 +56,21 @@ export class TeamsService {
|
||||
return this.db.select().from(teams);
|
||||
}
|
||||
|
||||
/**
|
||||
* List only the teams the user is a member of.
|
||||
*/
|
||||
async findAllForUser(userId: string) {
|
||||
const memberRows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, userId));
|
||||
|
||||
const teamIds = memberRows.map((r) => r.teamId);
|
||||
if (teamIds.length === 0) return [];
|
||||
|
||||
return this.db.select().from(teams).where(inArray(teams.id, teamIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a team by ID.
|
||||
*/
|
||||
|
||||
+242
-1014
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
---
|
||||
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
@@ -0,0 +1,328 @@
|
||||
# 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,3 +372,87 @@ The P0–P3 canon does not authorize:
|
||||
## 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.
|
||||
|
||||
## 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,9 +172,10 @@ export function registerGatewayCommand(program: Command): void {
|
||||
.command('recover-token')
|
||||
.description('Recover an admin token — prompts for login if no valid session exists')
|
||||
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
|
||||
.action(async (cmdOpts: { gateway?: string }) => {
|
||||
.option('-e, --email <email>', 'Headless: account email (password read from stdin line 2)')
|
||||
.action(async (cmdOpts: { gateway?: string; email?: string }) => {
|
||||
const { runRecoverToken } = await import('./gateway/token-ops.js');
|
||||
await runRecoverToken(cmdOpts.gateway);
|
||||
await runRecoverToken(cmdOpts.gateway, cmdOpts.email);
|
||||
});
|
||||
|
||||
// ─── logs ───────────────────────────────────────────────────────────────
|
||||
@@ -202,9 +203,14 @@ export function registerGatewayCommand(program: Command): void {
|
||||
|
||||
gw.command('uninstall')
|
||||
.description('Uninstall the gateway daemon and optionally remove data')
|
||||
.action(async () => {
|
||||
.option(
|
||||
'-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');
|
||||
await runUninstall();
|
||||
await runUninstall(cmdOpts);
|
||||
});
|
||||
|
||||
// ─── doctor ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -95,11 +95,17 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
|
||||
// fatal (#1392): an install that reports success over an empty/partial
|
||||
// database is the exact T63 failure this command must never reproduce.
|
||||
let verifyResult: VerifyResult | undefined;
|
||||
let verificationThrew = false;
|
||||
try {
|
||||
const { runPostInstallVerification } = await import('./verify.js');
|
||||
verifyResult = await runPostInstallVerification(configResult.host, configResult.port);
|
||||
} catch {
|
||||
// Non-fatal — verification is a courtesy
|
||||
} catch (err) {
|
||||
// Health/token/bootstrap courtesy failures are non-fatal, but a THROWN
|
||||
// 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) {
|
||||
prompter.warn(
|
||||
@@ -107,6 +113,12 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
|
||||
);
|
||||
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) {
|
||||
// Stages normally return structured results for expected failures.
|
||||
// Anything that reaches here is an unexpected runtime error — render a
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
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,11 +16,20 @@ vi.mock('./daemon.js', () => ({
|
||||
|
||||
vi.mock('./login.js', () => ({
|
||||
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
|
||||
// promptLine/promptSecret are used by ensureSession; return fixed values so tests don't block on stdin
|
||||
// promptLine/promptSecret are used by ensureSession on the TTY path; return fixed
|
||||
// values so tests never block on stdin.
|
||||
promptLine: vi.fn().mockResolvedValue('[email protected]'),
|
||||
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();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
@@ -65,7 +74,7 @@ describe('ensureSession', () => {
|
||||
expect(mockSignIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts for credentials and signs in when stored session is invalid', async () => {
|
||||
it('resolves piped-stdin credentials and signs in when stored session is invalid', async () => {
|
||||
mockLoadSession.mockReturnValueOnce({ cookie: 'old-cookie', userId: 'u1', email: '[email protected]' });
|
||||
mockValidateSession.mockResolvedValueOnce(false);
|
||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||
@@ -76,7 +85,7 @@ describe('ensureSession', () => {
|
||||
expect(mockSaveSession).toHaveBeenCalledWith(baseUrl, newAuth);
|
||||
});
|
||||
|
||||
it('prompts for credentials when no session exists', async () => {
|
||||
it('resolves piped-stdin credentials when no session exists', async () => {
|
||||
mockLoadSession.mockReturnValueOnce(null);
|
||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||
mockSignIn.mockResolvedValueOnce(newAuth);
|
||||
@@ -84,6 +93,10 @@ describe('ensureSession', () => {
|
||||
const cookie = await ensureSession(baseUrl);
|
||||
expect(cookie).toBe(fakeCookie);
|
||||
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 () => {
|
||||
@@ -111,7 +124,7 @@ describe('runRecoverToken', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('prompts for login, mints a token, and persists it when no session exists', async () => {
|
||||
it('signs in via piped stdin, mints a token, and persists it when no session exists', async () => {
|
||||
mockLoadSession.mockReturnValueOnce(null);
|
||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||
mockSignIn.mockResolvedValueOnce(newAuth);
|
||||
|
||||
@@ -153,4 +153,29 @@ describe('resolveSchemaCheckConfigPath', () => {
|
||||
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,8 +53,11 @@ export const SCHEMA_FAIL_REMEDIATION = [
|
||||
*/
|
||||
export function resolveSchemaCheckConfigPath(explicit?: string): string | undefined {
|
||||
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 = [
|
||||
process.env['MOSAIC_CONFIG'],
|
||||
join(homedir(), '.config', 'mosaic', 'gateway', 'mosaic.config.json'), // daemon-written
|
||||
resolve(process.cwd(), 'mosaic.config.json'),
|
||||
join(homedir(), '.mosaic', 'mosaic.config.json'),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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,6 +1,7 @@
|
||||
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
|
||||
import { readMeta, writeMeta } from './daemon.js';
|
||||
import { getGatewayUrl, promptLine, promptSecret } from './login.js';
|
||||
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
|
||||
|
||||
interface MintedToken {
|
||||
id: string;
|
||||
@@ -107,8 +108,24 @@ export async function requireSession(gatewayUrl: string): Promise<string> {
|
||||
* Ensure a valid session for the gateway, prompting for credentials if needed.
|
||||
* On sign-in failure, prints the error and exits non-zero.
|
||||
* 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 async function ensureSession(gatewayUrl: string): Promise<string> {
|
||||
export interface SessionCredentialOptions {
|
||||
/** 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
|
||||
const session = loadSession(gatewayUrl);
|
||||
if (session) {
|
||||
@@ -119,10 +136,25 @@ export async function ensureSession(gatewayUrl: string): Promise<string> {
|
||||
console.log(`No session found for ${gatewayUrl}. Please sign in.`);
|
||||
}
|
||||
|
||||
// Prompt for credentials — password must not be echoed to the terminal
|
||||
const email = await promptLine('Email: ');
|
||||
// Do not trim password — it may contain intentional leading/trailing whitespace
|
||||
const password = await promptSecret('Password: ');
|
||||
let email = opts.email;
|
||||
let password = opts.password;
|
||||
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
|
||||
password = await promptSecret('Password: ');
|
||||
}
|
||||
|
||||
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -146,11 +178,12 @@ export async function runRotateToken(gatewayUrl?: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* `mosaic gateway config recover-token` — prompts for login if no session exists.
|
||||
* `mosaic gateway config recover-token` — signs in if no session exists.
|
||||
* Passes the --email flag through to ensureSession (#1394 dual path).
|
||||
*/
|
||||
export async function runRecoverToken(gatewayUrl?: string): Promise<void> {
|
||||
export async function runRecoverToken(gatewayUrl?: string, email?: string): Promise<void> {
|
||||
const url = getGatewayUrl(gatewayUrl);
|
||||
const cookie = await ensureSession(url);
|
||||
const cookie = await ensureSession(url, { email });
|
||||
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
|
||||
const minted = await mintAdminToken(url, cookie, label);
|
||||
persistToken(url, minted);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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,30 +8,65 @@ import {
|
||||
uninstallGatewayPackage,
|
||||
} from './daemon.js';
|
||||
|
||||
export async function runUninstall(): Promise<void> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
export interface UninstallOptions {
|
||||
/** Skip the confirmation prompt (headless/scripted uninstall). */
|
||||
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 {
|
||||
await doUninstall(rl);
|
||||
await doUninstall(rl as NonNullable<typeof rl>, opts, nonInteractive);
|
||||
} finally {
|
||||
rl.close();
|
||||
rl?.close();
|
||||
}
|
||||
}
|
||||
|
||||
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||
function prompt(
|
||||
rl: NonNullable<ReturnType<typeof createInterface>>,
|
||||
question: string,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||
async function doUninstall(
|
||||
rl: ReturnType<typeof createInterface>,
|
||||
opts: UninstallOptions,
|
||||
nonInteractive: boolean,
|
||||
): Promise<void> {
|
||||
const meta = readMeta();
|
||||
if (!meta) {
|
||||
console.log('Gateway is not installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
|
||||
if (answer.toLowerCase() !== 'y') {
|
||||
console.log('Aborted.');
|
||||
return;
|
||||
if (nonInteractive) {
|
||||
console.log(`Uninstalling Mosaic Gateway (--yes${opts.removeData ? ' --remove-data' : ''})...`);
|
||||
} else {
|
||||
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
|
||||
if (answer.toLowerCase() !== 'y') {
|
||||
console.log('Aborted.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop if running
|
||||
@@ -45,13 +80,20 @@ async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
// Remove config/data
|
||||
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
|
||||
if (removeData.toLowerCase() === 'y') {
|
||||
// Remove config/data. Interactive: ask. Headless: only with the explicit
|
||||
// flag — destructive recursion is never implied by --yes alone (#1390).
|
||||
let removeData = Boolean(opts.removeData);
|
||||
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)) {
|
||||
rmSync(GATEWAY_HOME, { recursive: true, force: true });
|
||||
console.log('Gateway data removed.');
|
||||
}
|
||||
} else {
|
||||
console.log(`Gateway data kept at ${GATEWAY_HOME}.`);
|
||||
}
|
||||
|
||||
// Uninstall npm package
|
||||
|
||||
@@ -101,7 +101,7 @@ export async function runPostInstallVerification(
|
||||
const { runMigrations, getMigrationStatus } = await import('@mosaicstack/db');
|
||||
const result = await checkDatabaseSchema(
|
||||
{ runMigrations, getMigrationStatus },
|
||||
process.env['MOSAIC_CONFIG'],
|
||||
undefined, // resolver mirrors daemon file priorities; env has no config authority (N1)
|
||||
);
|
||||
if (result.status === 'ok') {
|
||||
ok(result.detail);
|
||||
|
||||
Reference in New Issue
Block a user