Compare commits

...
Author SHA1 Message Date
fred 3231b91691 docs: mode conversion revision 2 — identity precondition, preparation/flip boundary, vocabulary, witness matrix (luna F1-F7)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-26 19:16:17 -05:00
fred be32b693d0 docs: deployment mode and conversion contract (S2 contract 6)
ci/woodpecker/pr/ci Pipeline was canceled
2026-08-26 18:58:12 -05:00
fred 49b7943420 fix(gateway): scope /api/teams endpoints to team membership (#1428) (#1429)
ci/woodpecker/push/publish Pipeline is pending
2026-08-26 22:45:54 +00:00
fred 19e16bd44f ci: publish web+appservice sha images on next (#1407) (#1427)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-26 22:42:03 +00:00
jason.woltje 3bd490c080 Merge pull request 'docs: north-star PRD rewrite (D1-D14), ROADMAP, kanban SOT Amendment A1' (#1425) from docs/prd-north-star-rewrite into next
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: #1425
2026-08-26 16:57:16 +00:00
fred 4b448109dd docs/prd: address independent review findings 1-10 (fidelity, A1 record class + carve-out, roadmap completeness)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-25 23:20:40 -05:00
fred bc1149c15e docs: north-star PRD rewrite (D1-D14), ROADMAP.md, kanban SOT Amendment A1
ci/woodpecker/pr/ci Pipeline was successful
- docs/PRD.md: Part I product north star authored from ratified decisions
  D1-D14; Part II preserves all active workstream contracts verbatim
  (KBN-101, FCM #758, FCOM #766, TESS, #756, MOS-PORT, #1150, #1174, #1194,
  RI #1275, M1). Referenced anchors unchanged.
- docs/archive/PRD-v0.1.md: v0.1.0 beta PRD body archived verbatim with
  supersession header.
- docs/ROADMAP.md: all phases P0-P5 present from day one per D11
  (P2-P5 as explicit placeholders).
- docs/requirements/native-kanban-sot.md: Amendment A1 (D13) - hierarchy
  parentage + RBAC chain above workspaces; sections 1-7 untouched.
2026-08-25 22:23:07 -05:00
orch-01 089953a7cf ci: enable turbo remote cache on trusted publish events (#1424)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: orch-01 <[email protected]>
2026-08-25 18:32:55 +00:00
ops-deploy-01 7b25be22e9 fix(#1394): recover-token headless — dual path (--email flag + piped stdin) with documented precedence (#1423)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: ops-deploy-01 <[email protected]>
2026-08-25 16:14:56 +00:00
ops-deploy-01 4e3d179e61 fix(#1390): gateway uninstall headless — --yes/--remove-data; non-TTY without consent fails loud (#1422)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: ops-deploy-01 <[email protected]>
2026-08-25 16:00:17 +00:00
ops-deploy-01andorch-01 ae58482b72 fix(#1392): review-285 N1+N2 follow-up — env has no config authority in schema-check; verification throws fatal at install (#1421)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: ops-deploy-01 <[email protected]>
2026-08-25 15:08:04 +00:00
code-infra-01 b2d40dada0 fix(#1391): boot-time ValidationPipe metatype self-check — fail loud at startup (#1419)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-infra-01 <[email protected]>
2026-08-25 14:01:24 +00:00
code-be-02andorch-01 d30a4cce00 feat(git-tools): consume .mosaic/repo.json declarations in compat mode (T51 WP5b) (#1416)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-be-02 <[email protected]>
2026-08-25 12:55:40 +00:00
veronicaandorch-01 4cd280e48d feat(tools/git): grant-reviewer.sh org-team reviewer grant with fail-closed read-back (#1415) (#1417)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: veronica <[email protected]>
2026-08-25 02:30:33 +00:00
code-infra-01andorch-01 8738a03893 fix(#1395): accounts.issuer column + credential-only backfill — password auth on fresh installs (#1401)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-infra-01 <[email protected]>
2026-08-25 01:19:29 +00:00
41 changed files with 8836 additions and 1082 deletions
+6 -4
View File
@@ -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
View File
@@ -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"
+6
View File
@@ -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);
}
});
});
+94
View File
@@ -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 });
});
});
+45 -7
View File
@@ -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;
}
}
+16 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -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
+279
View File
@@ -0,0 +1,279 @@
# Deployment Mode and Conversion Contract (D3)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 6 of 9).
Authority: PRD D3 (Part I §3) — two modes chosen at install time,
Standalone and Enterprise, with the mode table (brains, user-data
isolation, secrets, conversion); Standalone → Enterprise conversion is
**one-way** and Enterprise is a **terminal state**. PRD D14 (Part I §7)
— the per-user brain split is optional in Standalone and keeping it is
the recommended default because it preserves forward-compatibility with
the one-way conversion. PRD D11 (Part I §9) — v1 ships the Standalone
flow only; Enterprise conversion is explicitly deferred. PRD D3
federation clause — federation is intentionally not fully designed,
deferred, and nothing in v1 may foreclose it.
Revision 2 (luna review F1F7): the identity precondition restated in
identity-contract terms with a conversion-local acknowledgment record
this contract owns (F1); a durable, keyed preparation state with a
Standalone-safe representation rule, an in-transaction re-check fence,
and an exact flip boundary (F2); the §5.4 unknown-value rule stated
directly without the contradictory non-exhaustiveness clause (F3); the
D14 boundary bound here with a stable column-allowlist witness instead
of delegated to an unratified layout (F4); the conversion witness
matrix extended to every §4.2/§4.4 condition (F5); the mode-record
writer coverage imported concretely from contract 1 §6.3 with a named
schema, closed writer set, crafted-write probe, and mode-resolution
assertion (F6); the mode read command flagged as a §12.1 drafting
addition rather than a D8 mandate (F7). Ownership language aligned
with contract 3 revision 2: mode is recorded at bootstrap and read by
the wizard as input.
This contract binds the mode as a canonical platform property (§2), the
per-mode obligations and which contract owns each (§3), the conversion
transition (§4), the v1 non-foreclosure obligations (§5), and their
witnesses (§6). Domain semantics stay with their owning contracts:
wizard branching (contract 3 §2), identity/SSO
(`identity-lifecycle.md`), custody and per-user brain mechanics
(contract 7, `custody-schema.md`), tool mapping
(`tool-gateway-mapping.md`).
## 1. Definitions
1. **Mode**: the platform-wide deployment mode, exactly one of
`standalone` or `enterprise`. The vocabulary is closed in v1;
extension (e.g. a federation mode) is by amendment to this contract,
never ad hoc.
2. **Conversion**: the one-way transition `standalone → enterprise`.
No other mode transition exists.
3. **Conversion preconditions**: the verifiable conditions of §4.2 that
must all hold before the mode record may change.
4. **Preparation unit**: one re-runnable piece of pre-conversion work —
the migration of one secret to the Vault backend, or the partition
of one user's brain content (§4.3).
## 2. Mode is a canonical recorded property
1. Mode is recorded canonically in the platform database at bootstrap
as the operator's install-time choice (D3: modes are "chosen at
install time"). The record is a single-row keyed record
(`platform_mode`: mode value, recorded-at timestamp, bootstrap epoch
reference); this contract owns it, the bootstrap writer performs the
one v1 write (§6.2), and the wizard reads it as input (contract 3
§2.3). Mode is never derived from feature state (presence of Vault,
count of brains, count of users), and no component may infer a
different mode than the record states.
2. The record is readable by any authenticated user through a Gateway
command with CLI exposure. This read command is a **drafting
addition** ratified with this contract (PRD §12.1), not a D8
mandate: D8 binds only that any surface exposing the value goes
through official tooling. When a webUI surface consumes the read, a
mapping row is added to `tool-gateway-mapping.md` by amendment —
the same route §4.4 already binds for the conversion command.
Components branch on the read value only.
3. The record is immutable except by the §4 conversion transition.
Editing it by direct database access, config file, environment
variable, or wizard re-run is non-conformant (contract 3 §2.3:
changing mode later is conversion, not a wizard re-run).
## 3. Per-mode obligations (owner map)
The PRD mode table binds four rows; this contract assigns each an
owning contract so no obligation is unowned and none is bound twice:
| Obligation | Standalone | Enterprise | Owner |
| ------------------- | -------------------------------------- | -------------------------------------------------- | --------------------------------------------- |
| Brains | one mosaic-brain (system + user files) | system brain for config + one brain per user | contract 7 (custody/brain mechanics) |
| User-data isolation | single user | no user-data leakage between users; sharing opt-in | contract 7 (enforced by architecture, D14) |
| Secrets | OpenBao/Vault or flat files | OpenBao/Vault REQUIRED | this contract (§4.2 gate; steady-state check) |
| Conversion | may convert to Enterprise, one-way | terminal state | this contract (§4) |
The Standalone brains row states the default layout, not the only
valid one: the D14 per-user split is a MAY in Standalone with keeping
it the recommended default (PRD §7, contract 7 §6), and Vault-backed
secrets are equally valid Standalone configuration. Both prepared
states are therefore themselves valid Standalone states — the fact
§4.3 relies on.
In Enterprise steady state, a flat-file secrets backend is
non-conformant; the platform refuses to start Enterprise-mode
components against a flat-file secrets configuration (fail-closed, not
warn-and-run).
## 4. Conversion transition
1. **Direction and terminality.** The only transition is
`standalone → enterprise`. `enterprise → standalone` does not exist:
there is no command, no admin override, and no support path. An
attempt is refused with the precondition/state error class of the
command envelope (`tool-gateway-mapping.md` §4.2).
2. **Preconditions (all verified before the record changes):**
- Secrets: OpenBao/Vault is configured and reachable, and every
required secret is served from the Vault backend — none from a
flat-file backend. Secret migration completes before conversion;
this contract does not define the migration tooling, only the
gate.
- Brains: the per-user brain split required by the Enterprise row of
§3 is established for **every** existing user (or the deployment
already kept the split, the D14 recommended default). Brain
partitioning mechanics are contract 7; this contract binds only
that the split is complete before the mode flips.
- Identity: at least one platform administrator account exists that
is active in identity-contract terms — authenticated capability,
not banned, not deactivated (identity §2, §5). And the conversion
request carries a **configuration acknowledgment**: the current
canonical values of registration mode and per-provider JIT
enablement (identity §2.2, §4.1), echoed back in the request. A
mismatch between the echoed values and the canonical values at
verification refuses the conversion. This acknowledgment record
is conversion-local, owned by this contract, and stored with the
§4.4 audit event as the precondition evidence; it adds no
identity-contract obligation and no mode-specific identity
default — identity's own defaults remain valid states.
3. **Preparation state and the flip boundary.** Preparatory work is
tracked durably: each preparation unit (§1.4) records its
completion in a preparation table keyed by (bootstrap epoch, unit
identity — the secret's path, the user's id), written in the same
transaction as the unit's own effect where the unit's backend
allows it, and reconciled from the backend's actual state where it
does not (a secret already served by Vault, a brain already split,
is complete regardless of the table). Units are at-most-once per
key and re-runnable across attempts. **Standalone-safe
representation:** every preparation unit moves the deployment into
a state that is itself valid Standalone configuration (§3 note), so
an interrupted preparation leaves a fully operational Standalone
deployment reading its state through the ordinary contracts — no
rollback, fencing, or special Standalone read path is needed, and
no component behavior may key on "preparation in progress".
**The flip:** one transaction that (a) locks the mode record, (b)
re-verifies every §4.2 precondition after acquiring the lock, and
(c) writes the mode record and the §4.4 audit event. Any re-check
failure aborts with no write. External state that changes after the
re-check but before commit is bounded by the transaction window;
an external backend (Vault) failing after conversion is an
Enterprise runtime fault handled by §3's fail-closed steady-state
rule, not a conversion defect. An interrupted or failed conversion
leaves the record `standalone` and the platform fully operational;
there is no intermediate mode and no half-converted state
observable through the record.
4. **Authority and audit.** Conversion is a platform-administrator
command carrying an explicit irreversibility acknowledgment in its
request (distinct from the §4.2 configuration acknowledgment). It
is an official Gateway/CLI command (D8): when built, it is added to
the tool↔Gateway mapping by amendment (`tool-gateway-mapping.md`
§3.3). The transition emits an audit event (actor, prior mode, new
mode, precondition evidence reference including the configuration
acknowledgment) in the same transaction as the record change; the
event survives indefinitely. A refused attempt emits a refusal
event naming the failed precondition class and actor, with no
mode-change event.
## 5. v1 obligations (non-foreclosure)
v1 ships Standalone only (D11); the conversion command is deferred
work. v1 still MUST:
1. Record the mode per §2 at bootstrap, with `enterprise` a reserved,
refused value for bootstrap — v1 bootstrap accepts `standalone`
only. The wizard reads the record (contract 3 §2.3); nothing in v1
writes it after bootstrap.
2. Keep the §2.3 immutability rule: no v1 surface mutates the mode
record.
3. Not foreclose conversion: the v1 platform database holds no
sensitive user content — sensitive categories live in the owning
user's brain, and postgres holds structure, consent records, and
pointers only (the D14 boundary, PRD §7). Custody mechanics are
contract 7's; this contract binds the boundary itself here so v1
cannot ship a layout that makes the §4.2 brain precondition
unsatisfiable, and §6.3 gives it a stable witness that does not
depend on contract 7's internals. Conversion implementation
additionally requires contract 7 ratified.
4. Not foreclose federation: v1 components accept exactly the two §1.1
values wherever a mode value is parsed and refuse any other value
**before side effects** — a refused configuration, not undefined
behavior and not a crash mid-operation. Forward compatibility lives
in storage and architecture, not in parser speculation: the mode
record's storage is not structurally locked to two values (no
database-level two-value enum), and any future value (e.g. a
federation mode) is defined by a versioned amendment to this
contract before any component accepts it. The PRD defers
federation's shape entirely; this contract does not presume it
arrives as a third mode value.
## 6. Verification requirements
Binding on the implementing PRs:
1. **Mode-record witness (v1):** after bootstrap the mode is readable
via the Gateway command and CLI and equals the bootstrap-recorded
choice; bootstrap with mode `enterprise` is refused; bootstrap with
any unknown mode value is refused before side effects (§5.4).
2. **Writer-coverage witness (v1):** the mode record's writer set is
closed by the same three-prong static assertion contract 1 §6.3(b)
defines — symbol, class-table literal, and raw-execution prongs
with its allowlist composition rules — scoped to the
`platform_mode` table, with a writer allowlist containing exactly
the bootstrap writer in v1 (and exactly plus the conversion command
at the conversion milestone). Companions: a crafted direct write
attempted in a test fails and leaves the record unchanged; a
mode-resolution assertion that no shipped component derives mode
from feature state (mode reads occur only through the §2.2 read
surface — static assertion over Gateway, CLI, bootstrap, and
repository sources).
3. **D14-boundary witness (v1):** a column-allowlist assertion in the
style of contract 1 §6.2 that the platform database schema contains
no sensitive-content column — the §5.3 boundary — stable regardless
of contract 7's internals (contract 7 §7 carries the full custody
witnesses).
4. **No-downgrade witness (conversion milestone):** with mode
`enterprise`, a conversion request to `standalone` (and any crafted
mode-write) is refused with the precondition/state error class and
no record change.
5. **Precondition witnesses (conversion milestone),** each refused
with no record change and no partial mode effect, parameterized
over both OpenBao and Vault where secrets are involved:
(a) secrets backend unreachable; (b) one required secret still
flat-file backed (migration incomplete); (c) one unpartitioned user
brain in a **multi-user** deployment where every other user is
partitioned; (d) no active platform administrator (the only admin
banned or deactivated); (e) configuration acknowledgment missing or
mismatching the canonical registration/JIT values; (f) actor not a
platform administrator (authorization refusal); (g) irreversibility
acknowledgment absent. And the steady-state rule: an
Enterprise-mode component started against a flat-file secrets
configuration refuses to start (§3).
6. **Interruption and fence witnesses (conversion milestone):** fault
injection aborting conversion after each preparation unit and
between preparation and flip leaves the record `standalone` and the
platform operational in Standalone semantics (§4.3
Standalone-safety), and a re-attempt completes without duplicating
prepared state (at-most-once keys); a precondition invalidated
after preparation but before the flip (a secret reverted to
flat-file) is caught by the in-transaction re-check and refused.
7. **Audit witnesses (conversion milestone):** a completed conversion
has exactly one mode-change audit event, same-transaction with the
record change (transaction linkage asserted), carrying actor, prior
mode, new mode, and the precondition evidence reference including
the configuration acknowledgment; a failed attempt has a refusal
event naming the failed precondition class and no mode-change
event; the mode-change event remains queryable after subsequent
unrelated audit activity (retention probe).
8. **Mapping witness (conversion milestone):** the conversion command
and the mode read command each have their
`tool-gateway-mapping.md` row (added by amendment per §2.2/§4.4)
before the commands ship.
## Ruling request
Ratify sections 16 as written, with one decision embedded:
- Decision (§5): v1 implements the **mode record and its immutability
only** — bootstrap records `standalone`, the `enterprise` value is
reserved and refused, and the conversion command itself is deferred
to the Enterprise milestone, consistent with D11's deferred list.
v1 carries three obligations beyond the record: the closed writer
assertion, the D14 column boundary, and the unknown-value refusal
(§6.1–§6.3) — these are the non-foreclosure floor, not hidden
conversion work. Alternative if rejected: build the conversion
command inside v1 — rejected because D11 scopes v1 to the Standalone
slice and conversion depends on contract 7 custody mechanics that
are themselves not in the v1 slice.
+84
View File
@@ -372,3 +372,87 @@ The P0P3 canon does not authorize:
## 7. Global release evidence
P0P3 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 17, 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 34), 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).
@@ -0,0 +1,9 @@
ALTER TABLE "accounts" ADD COLUMN "issuer" text;
--> statement-breakpoint
-- Backfill (#1395): better-auth >=1.7 sign-in filters accounts on
-- (provider_id = 'credential' AND issuer = 'local:credential'). Existing
-- credential rows predate the column and would fail that filter on upgraded
-- installs. Credential rows ONLY: better-auth owns issuer semantics for
-- oauth/sso rows going forward (each provider's real issuer value), so those
-- stay NULL until the provider's next flow writes them.
UPDATE "accounts" SET "issuer" = 'local:credential' WHERE "provider_id" = 'credential' AND "issuer" IS NULL;
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -120,6 +120,13 @@
"when": 1784050648841,
"tag": "0016_salty_morlocks",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1787609223282,
"tag": "0017_accounts_issuer",
"breakpoints": true
}
]
}
}
+69
View File
@@ -51,6 +51,75 @@ describe('runPgliteMigrations', () => {
await expect(runPgliteMigrations(handle)).resolves.toBeUndefined();
});
it('gives accounts an issuer column (#1395) — better-auth >=1.7 requires it', async () => {
await runPgliteMigrations(handle);
const result = (await handle.db.execute(sql`
SELECT column_name, is_nullable, data_type
FROM information_schema.columns
WHERE table_name = 'accounts' AND column_name = 'issuer'
`)) as unknown as {
rows: Array<{ column_name: string; is_nullable: string; data_type: string }>;
};
// Nullable by design: the 1.5.x line this repo's lockfile resolves to does
// not write the field; 1.7+ populates it. One schema serves both.
expect(result.rows).toHaveLength(1);
expect(result.rows[0]?.is_nullable).toBe('YES');
expect(result.rows[0]?.data_type).toBe('text');
});
it('backfills ONLY credential rows with the synthetic issuer (#1395 upgrade path)', async () => {
// Simulate an upgraded install: migrate through 0016 only, seed pre-issuer
// rows (one credential, one oauth), then apply 0017 and discriminate.
const client = (handle.db as unknown as { $client: PgliteExec }).$client;
// Migrate to 0016 by replaying every ledger file except 0017 — the ledger
// table gates re-application, so a plain replay of 0000..0016 is enough.
const fs = await import('node:fs');
const path = await import('node:path');
const dir = path.join(import.meta.dirname, '..', 'drizzle');
const files = fs
.readdirSync(dir)
.filter((f) => /^\d{4}_.*\.sql$/.test(f) && f < '0017')
.sort();
for (const f of files) {
const raw = fs.readFileSync(path.join(dir, f), 'utf-8');
for (const stmt of raw.split('--> statement-breakpoint')) {
const trimmed = stmt.trim();
if (trimmed) await client.exec(trimmed);
}
}
await client.exec(`
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
VALUES ('u1', 'Legacy User', '[email protected]', true, now(), now());
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
VALUES
('a1', '[email protected]', 'credential', 'u1', now(), now()),
('a2', 'oauth-provider-1', 'google', 'u1', now(), now());
`);
// Apply 0017 (column + backfill).
const sql0017 = fs.readFileSync(path.join(dir, '0017_accounts_issuer.sql'), 'utf-8');
for (const stmt of sql0017.split('--> statement-breakpoint')) {
const trimmed = stmt.trim();
if (trimmed) await client.exec(trimmed);
}
const rows = (await handle.db.execute(sql`
SELECT provider_id, issuer FROM accounts ORDER BY id
`)) as unknown as { rows: Array<{ provider_id: string; issuer: string | null }> };
const byProvider = new Map(rows.rows.map((r) => [r.provider_id, r.issuer]));
// Credential rows get better-auth's synthetic local issuer — the value
// sign-in filters on (better-auth dist createLocalAccountIssuer).
expect(byProvider.get('credential')).toBe('local:credential');
// OAuth rows are LEFT NULL: better-auth owns their issuer semantics going
// forward (each provider's real issuer on its next flow).
expect(byProvider.get('google')).toBeNull();
});
it('surfaces statement-level error context on failure and leaves no ledger row', async () => {
// Pre-create a `users` table that conflicts with migration 0000's CREATE TABLE,
// forcing it to fail without IF NOT EXISTS.
+6
View File
@@ -63,6 +63,12 @@ export const accounts = pgTable(
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
// better-auth >=1.7 requires an issuer on every account row: credential
// sign-up writes the synthetic 'local:credential', OAuth rows carry the
// provider's real issuer, and sign-in filters on (providerId, issuer).
// Nullable because the 1.5.x line this repo's lockfile resolves to does
// not know the field — 1.5 ignores it, 1.7 populates it (#1395).
issuer: text('issuer'),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
@@ -23,8 +23,14 @@ absent. Do not use raw `tmux send-keys` for fleet messaging.
```bash
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...
tools/git/ci-queue-wait.sh --purpose push|merge # REQUIRED before any push/merge
tools/git/repo-decl.sh # shared .mosaic/repo.json consumption lib (sourced)
```
**Reviewer grants**`tools/git/grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]` adds a
review seat to an org repo through an org team (Gitea only; code read + issues/pulls write, verified
by read-back). Team approvals do not count as official under branch protection unless the team is
whitelisted — see the tool header.
**GITEA_LOGIN gotcha** — the wrappers default to login `mosaicstack`; on a USC repo that fails with
`gitea / Error: GetUserByName ... not found`. Pick the login from the repo's `origin` host first:
@@ -49,6 +49,10 @@ supply it explicitly on any host where the provider CLI's default account is an
| `milestone-list.sh` | List milestones |
| `milestone-close.sh` | Close a milestone |
| Access grants | |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `grant-reviewer.sh` | Grant a review seat on an org repo via an org team (Gitea only): code read + issues/pulls write, verified by read-back. Team approvals count as official only if branch protection whitelists the team — see the tool header |
| Gates and guards | |
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) |
@@ -503,6 +503,28 @@ if [[ -z "$BRANCH" ]]; then
fi
fi
# T51 WP5b (spec 4.1, review ruling C4/F8): the declaration adds ROUTE CONTEXT only.
# Branch-selection semantics are UNCHANGED — the guard keeps inspecting the
# exact head above. No declaration dependency gates the wait (4.3/DR2 R9:
# blocking here adds a blocker with no safety gain); absence is silent.
# shellcheck source=packages/mosaic/framework/tools/git/ci-queue-wait.sh
if git rev-parse --show-toplevel >/dev/null 2>&1 \
&& [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/repo-decl.sh" ]; then
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/repo-decl.sh"
repo_decl_load
if [[ "$DECL_STATE" == invalid ]]; then
repo_decl_report_invalid
elif [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
route="feature"
if [[ "$BRANCH" == "$DECL_TRUNK" ]]; then
route="trunk (integration head)"
elif [[ "$BRANCH" == "$DECL_RELEASE" ]]; then
route="release branch"
fi
echo "repo-decl: route context flow=$DECL_FLOW trunk=$DECL_TRUNK release=$DECL_RELEASE; guarded head '$BRANCH' is a $route head (spec 4.1)" >&2
fi
fi
if [[ "$PLATFORM" == "github" ]]; then
if ! command -v gh >/dev/null 2>&1; then
record_cannot_assert "github-cli-unavailable"
+343
View File
@@ -0,0 +1,343 @@
#!/bin/bash
# grant-reviewer.sh - Grant a reviewer read + review access to an org-owned
# Gitea repository via an org team (default: fleet-reviewers).
#
# Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]
#
# The team carries `permission: read` with per-unit overrides
# {repo.code: read, repo.issues: write, repo.pulls: write}: the reviewer can
# read code and write issues/PR reviews, but cannot push. The grant is
# idempotent — the team is looked up before it is created, and member/repo
# additions are PUTs.
#
# KNOWN LIMITATION — branch protection counts these reviews as UNOFFICIAL.
# Gitea computes a review's `official` flag at SUBMISSION time, from write
# permission on the repo or from membership in the protected branch's
# approvals whitelist (disabled by default). A team granted through this
# script has read permission on code, so under branch protection with
# required_approvals the reviewer's approval shows but does NOT count toward
# the required total — the merge still fails with "not enough approvals".
# Enabling the approvals whitelist and adding this team to it is review
# policy (who counts as an official approver), an operator decision made in
# the repo's branch-protection settings, deliberately NOT automated here.
# Because `official` is fixed at submission, whitelisting after the fact
# requires the review to be re-submitted before it counts.
#
# Platform: Gitea only. On a GitHub-remoted repo this script refuses to run —
# GitHub review access is granted through collaborator/team facilities that
# have no equivalent to Gitea's org-team unit map.
#
# Identity: the acting credential resolves exactly as in issue-comment.sh —
# GITEA_LOGIN (when set) names a tea login whose token MUST resolve for the
# remote host (fail closed, never downgrade to the host default identity);
# otherwise the per-seat identity ladder in detect-platform.sh applies
# (MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity → per-slot token,
# fail-loud on fleet hosts). Managing org teams requires org owner/admin:
# an HTTP 403 from any step is reported as "org admin required on <org>",
# never as a silent partial grant.
#
# Verification is fail-closed: after the member and repo PUTs, the script
# GETs the single resources back (GET /teams/{id}/members/{user} and
# GET /teams/{id}/repos/{owner}/{repo}) and refuses to report success unless
# both confirm the grant. A PUT that returns success without persisting
# (the #865 defect class: an exit code is not evidence of a durable write)
# therefore fails the run instead of reporting a grant that does not exist.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
usage() {
echo "Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]"
echo ""
echo "Options:"
echo " -u, --user Gitea username to grant reviewer access (required)"
echo " -r, --repo Target repository as <owner>/<repo>; defaults to the"
echo " current repository's origin. The owner must be an"
echo " organization."
echo " -t, --team Org team to use/create (default: fleet-reviewers)"
echo " -h, --help Show this help"
echo ""
echo "Environment:"
echo " GITEA_LOGIN Override the acting identity with a named tea login"
echo " (must resolve for the remote host; fails closed)."
echo ""
echo "Grants: code read + issues/pulls write via an org team. Gitea only."
echo ""
echo "LIMITATION: under branch protection with required approvals, reviews"
echo "from a read-permission team are official=false and do not count"
echo "toward the required total. Making them count means enabling the"
echo "protected branch's approvals whitelist and adding the team — an"
echo "operator review-policy decision this script does not automate. The"
echo "official flag is computed at review submission, so a review made"
echo "before whitelisting must be re-submitted afterwards."
}
REVIEWER=""
REPO_OVERRIDE=""
TEAM="fleet-reviewers"
while [[ $# -gt 0 ]]; do
case $1 in
-u|--user)
REVIEWER="$2"
shift 2
;;
-r|--repo)
REPO_OVERRIDE="$2"
shift 2
;;
-t|--team)
TEAM="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
if [[ -z "$REVIEWER" ]]; then
echo "Error: reviewer username is required (-u)" >&2
exit 1
fi
# Gitea usernames and team names are AlphaDashDot. Validating here keeps the
# values safe to interpolate into API paths without URL-encoding.
NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$'
if ! [[ "$REVIEWER" =~ $NAME_RE ]]; then
echo "Error: invalid reviewer username '$REVIEWER'" >&2
exit 1
fi
if ! [[ "$TEAM" =~ $NAME_RE ]]; then
echo "Error: invalid team name '$TEAM'" >&2
exit 1
fi
if [[ -n "$REPO_OVERRIDE" ]] && ! [[ "$REPO_OVERRIDE" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "Error: -r expects <owner>/<repo>, got '$REPO_OVERRIDE'" >&2
exit 1
fi
detect_platform >/dev/null
if [[ "$PLATFORM" != "gitea" ]]; then
echo "Error: grant-reviewer.sh is Gitea only (detected platform: $PLATFORM)." >&2
echo " On GitHub, grant review access via repository collaborators or org teams in the GitHub UI/CLI." >&2
exit 1
fi
HOST=$(get_remote_host) || {
echo "Error: could not resolve the remote host from origin" >&2
exit 1
}
# Acting credential: GITEA_LOGIN (explicit, fail closed) or the identity
# ladder. Same ordering contract as issue-comment.sh — an explicit override is
# never silently downgraded to the host default identity.
if [[ -n "${GITEA_LOGIN:-}" ]]; then
GITEA_API_TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN" "$HOST") || {
echo "Error: could not resolve a host-matched Gitea token for GITEA_LOGIN '$GITEA_LOGIN' on host '$HOST'; refusing to fall back to the host default identity (reviewer grant)" >&2
exit 1
}
else
GITEA_API_TOKEN=$(get_gitea_token "$HOST") || {
echo "Error: no Gitea credential resolved for the acting identity on host '$HOST' (reviewer grant). Set MOSAIC_GIT_IDENTITY=<agent-id>, or set GITEA_LOGIN=<name> to use a named tea credential." >&2
exit 1
}
fi
CONFIGURED_URL=$(get_gitea_url_for_host "$HOST") || {
echo "Error: configured Gitea URL not found for host '$HOST'" >&2
exit 1
}
GITEA_API_ROOT="${CONFIGURED_URL%/}/api/v1"
if [[ -n "$REPO_OVERRIDE" ]]; then
REPO_SLUG="$REPO_OVERRIDE"
else
REPO_SLUG=$(get_gitea_repo_slug_for_url "$CONFIGURED_URL") || {
echo "Error: could not resolve <owner>/<repo> from origin; pass -r <owner>/<repo>" >&2
exit 1
}
fi
ORG="${REPO_SLUG%%/*}"
REPO_NAME="${REPO_SLUG#*/}"
RESPONSE_FILE=$(mktemp "${TMPDIR:-/tmp}/mosaic-grant-reviewer-resp.XXXXXX")
AUTH_CONFIG=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$RESPONSE_FILE"
echo "Error: could not stage Gitea credential for reviewer grant" >&2
exit 1
}
trap 'rm -f "$RESPONSE_FILE" "$AUTH_CONFIG"' EXIT
# gitea_api <step> <method> <path> [json-payload]
# Runs one API call with the staged credential (token never in argv). Sets
# GITEA_API_STATUS and leaves the body in $RESPONSE_FILE. Transport failure
# and HTTP 403 are terminal here: 403 on ANY step means the acting identity
# cannot manage org teams, and the run must stop rather than continue into a
# partial grant.
gitea_api() {
local step="$1" method="$2" path="$3" payload="${4:-}"
local -a payload_args=()
if [[ -n "$payload" ]]; then
payload_args=(-H 'Content-Type: application/json' -d "$payload")
fi
if ! GITEA_API_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
-X "$method" \
--config "$AUTH_CONFIG" \
"${payload_args[@]}" \
"$GITEA_API_ROOT$path"); then
echo "Error: Gitea transport failed during $step" >&2
return 1
fi
if [[ "$GITEA_API_STATUS" == "403" ]]; then
echo "Error: HTTP 403 during $step: org admin required on '$ORG' — managing org teams needs owner/admin on the organization. No grant was completed." >&2
return 1
fi
return 0
}
# json_field <file> <key> — print a top-level scalar field or fail.
json_field() {
python3 - "$1" "$2" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
data = json.load(response)
value = data.get(sys.argv[2]) if isinstance(data, dict) else None
if value is None or isinstance(value, (dict, list, bool)):
raise ValueError(f"missing or non-scalar field {sys.argv[2]!r}")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: unusable Gitea response: {error}", file=sys.stderr)
raise SystemExit(1)
print(value)
PY
}
# 1. The owner must be an organization: teams are an org facility, and a
# user-owned repo would fail later with a misleading team error.
gitea_api "organization check" GET "/orgs/$ORG"
if [[ "$GITEA_API_STATUS" == "404" ]]; then
echo "Error: owner '$ORG' is not an organization on '$HOST'; grant-reviewer requires an org-owned repository" >&2
exit 1
fi
if [[ "$GITEA_API_STATUS" != "200" ]]; then
echo "Error: organization check for '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
exit 1
fi
# 2. Idempotent team resolution: exact-name lookup first, create only on miss.
# The search endpoint substring-matches, so the exact-name filter is done
# on the response, not trusted to the query.
gitea_api "team lookup" GET "/orgs/$ORG/teams/search?q=$TEAM"
if [[ "$GITEA_API_STATUS" != "200" ]]; then
echo "Error: team lookup for '$TEAM' on '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
exit 1
fi
TEAM_ID=$(TEAM_NAME="$TEAM" python3 - "$RESPONSE_FILE" <<'PY'
import json
import os
import sys
wanted = os.environ["TEAM_NAME"]
try:
with open(sys.argv[1], encoding="utf-8") as response:
result = json.load(response)
teams = result.get("data") if isinstance(result, dict) else None
if not isinstance(teams, list):
raise ValueError("team search response carried no data list")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: unusable team search response: {error}", file=sys.stderr)
raise SystemExit(1)
for team in teams:
if isinstance(team, dict) and team.get("name") == wanted:
team_id = team.get("id")
if not isinstance(team_id, int) or team_id <= 0:
print("Error: matched team carried no positive id", file=sys.stderr)
raise SystemExit(1)
print(team_id)
raise SystemExit(0)
print("")
PY
)
if [[ -z "$TEAM_ID" ]]; then
CREATE_PAYLOAD=$(TEAM_NAME="$TEAM" python3 -c '
import json
import os
print(json.dumps({
"name": os.environ["TEAM_NAME"],
"description": "review seats: code read + issues/pulls write",
"permission": "read",
"includes_all_repositories": False,
"can_create_org_repo": False,
"units_map": {
"repo.code": "read",
"repo.issues": "write",
"repo.pulls": "write",
},
}))
')
gitea_api "team create" POST "/orgs/$ORG/teams" "$CREATE_PAYLOAD"
if [[ "$GITEA_API_STATUS" != "201" ]]; then
echo "Error: team create for '$TEAM' on '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
exit 1
fi
TEAM_ID=$(json_field "$RESPONSE_FILE" id) || {
echo "Error: team create returned no usable team id" >&2
exit 1
}
echo "Created team '$TEAM' (id $TEAM_ID) on org '$ORG'"
else
echo "Found existing team '$TEAM' (id $TEAM_ID) on org '$ORG'"
fi
# 3. Membership and repo attachment — both PUTs, both idempotent in Gitea.
gitea_api "member add" PUT "/teams/$TEAM_ID/members/$REVIEWER"
if [[ "$GITEA_API_STATUS" != "204" ]]; then
echo "Error: adding '$REVIEWER' to team '$TEAM' failed with HTTP $GITEA_API_STATUS" >&2
exit 1
fi
gitea_api "repo add" PUT "/teams/$TEAM_ID/repos/$ORG/$REPO_NAME"
if [[ "$GITEA_API_STATUS" != "204" ]]; then
echo "Error: adding repo '$REPO_SLUG' to team '$TEAM' failed with HTTP $GITEA_API_STATUS" >&2
exit 1
fi
# 4. Fail-closed read-back: a 204 from a PUT is an exit code, not evidence the
# grant persisted. GET the single resources back and require both.
gitea_api "member read-back" GET "/teams/$TEAM_ID/members/$REVIEWER"
if [[ "$GITEA_API_STATUS" != "200" ]]; then
echo "Error: reviewer grant NOT verified — GET /teams/$TEAM_ID/members/$REVIEWER returned HTTP $GITEA_API_STATUS after a successful PUT. Treat the grant as not made." >&2
exit 1
fi
READBACK_LOGIN=$(json_field "$RESPONSE_FILE" login) || exit 1
if [[ "${READBACK_LOGIN,,}" != "${REVIEWER,,}" ]]; then
echo "Error: reviewer grant NOT verified — member read-back returned login '$READBACK_LOGIN', expected '$REVIEWER'" >&2
exit 1
fi
gitea_api "repo read-back" GET "/teams/$TEAM_ID/repos/$ORG/$REPO_NAME"
if [[ "$GITEA_API_STATUS" != "200" ]]; then
echo "Error: reviewer grant NOT verified — GET /teams/$TEAM_ID/repos/$ORG/$REPO_NAME returned HTTP $GITEA_API_STATUS after a successful PUT. Treat the grant as not made." >&2
exit 1
fi
READBACK_FULL_NAME=$(json_field "$RESPONSE_FILE" full_name) || exit 1
if [[ "${READBACK_FULL_NAME,,}" != "${REPO_SLUG,,}" ]]; then
echo "Error: reviewer grant NOT verified — repo read-back returned '$READBACK_FULL_NAME', expected '$REPO_SLUG'" >&2
exit 1
fi
echo "Granted: '$REVIEWER' is a member of team '$TEAM' (id $TEAM_ID) with access to '$REPO_SLUG' (code read, issues/pulls write) — verified by read-back"
echo "Note: under branch protection with required approvals this reviewer's approvals are official=false unless the branch's approvals whitelist includes the team (operator decision; reviews submitted before whitelisting must be re-submitted)."
@@ -192,6 +192,47 @@ cmd_new() {
local path; path="$(derive_path "$branch")"
assert_not_home "$path"
# T51 WP5b (spec 4.4 staged rule + 4.5 advisory policy): placement stays
# DERIVED; the declaration never moves the worktree. An INVALID declaration
# fails branch-creation loud (a broken structure file must not ride a new
# branch); an absent one warns (rollout window, Q-C); a valid one contributes
# policy ADVICE only. The advisory worktree_root comparison runs only when
# MOSAIC_HOST_ROOT is set (1.2a: warn-and-omit for advisory display).
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
_rd="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/repo-decl.sh"
if [ -f ""$_rd"" ]; then
source ""$_rd""
repo_decl_load
else
DECL_STATE=absent; DECL_SCHEMA=""
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
repo_decl_report_invalid() { :; }
repo_decl_warn_absent_reversible() { :; }
repo_decl_warn_absent_irreversible() { :; }
repo_decl_remote_matches() { return 0; }
repo_decl_check_transition() { return 2; }
fi
case "$DECL_STATE" in
invalid)
repo_decl_report_invalid
die "worktree branch-creation refused: the structure declaration is invalid (spec 4.4 — fix it before creating branches)"
;;
absent)
repo_decl_warn_absent_irreversible "worktree branch-creation"
;;
valid)
if [ "$DECL_SCHEMA" = 2 ] && [ "$DECL_POLICY" = "orchestrator-precreated" ]; then
echo "repo-decl: worktree_policy=orchestrator-precreated (TRANSITIONAL, spec 4.5): tasking pre-creates worktrees; creating one directly is the interim path until the J3/#1174 amendment unblocks the wrapper consumer." >&2
fi
if [ -n "${MOSAIC_HOST_ROOT:-}" ] && [ -n "$DECL_WT_ROOT" ]; then
dwt="$(repo_decl_path "$DECL_WT_ROOT" 2>/dev/null || true)"
if [ -n "$dwt" ] && [ "${dwt%/}" != "${WT_ROOT%/}" ]; then
echo "repo-decl: derived root $WT_ROOT diverges from the declared advisory worktree_root $dwt (advisory per spec 4.1/5.2 — placement stays derived)" >&2
fi
fi
;;
esac
if [ -e "$path" ]; then
echo "exists: $path"
echo "(already checked out — reuse it, or 'rm' it first)"
@@ -78,8 +78,8 @@ gitea_pr_create_api() {
# historical "main" literal, which mistargeted every fallback PR on
# repos whose trunk is not main (e.g. mosaicstack/stack -> next).
local api_base=""
if [[ -n "$BASE_BRANCH" ]]; then
api_base="$BASE_BRANCH"
if [[ -n "$EFFECTIVE_BASE" ]]; then
api_base="$EFFECTIVE_BASE"
else
api_base=$(gitea_default_branch) || {
echo "Error: could not resolve the forge default branch for the API-fallback base; pass -B <branch> explicitly" >&2
@@ -198,6 +198,52 @@ if [[ -z "$HEAD_BRANCH" ]]; then
HEAD_BRANCH=$(git branch --show-current)
fi
# T51 WP5b: declaration-driven base resolution (spec 4.1). Precedence:
# explicit -B -> validated as an ALLOWED transition (4.2: a flag is input,
# not authority) when a consumable declaration exists
# declared trunk (v2 declarations only) -> used directly
# legacy -> WP5a forge-default floor (unmanaged/absent/v1, 4.3)
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
source "$SCRIPT_DIR/repo-decl.sh"
repo_decl_load
else
DECL_STATE=absent; DECL_SCHEMA=""
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
repo_decl_report_invalid() { :; }
repo_decl_warn_absent_reversible() { :; }
repo_decl_warn_absent_irreversible() { :; }
repo_decl_remote_matches() { return 0; }
repo_decl_check_transition() { return 2; }
fi
EFFECTIVE_BASE="$BASE_BRANCH"
case "$DECL_STATE" in
invalid) repo_decl_report_invalid ;;
esac
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" != 2 ]]; then
repo_decl_warn "declaration is v$DECL_SCHEMA — carries no consumable flow/trunk fields; legacy behavior"
fi
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
# Write path: a normalized-remote mismatch refuses (spec 5.3).
if ! repo_decl_remote_matches; then
echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to create a PR against the wrong forge. Fix the origin remote or the declaration." >&2
exit 1
fi
if [[ -n "$BASE_BRANCH" ]]; then
trc=0
repo_decl_check_transition "$HEAD_BRANCH" "$BASE_BRANCH" || trc=$?
if [[ "$trc" == 1 ]]; then
echo "Error: -B '$BASE_BRANCH' is not an allowed transition for head '$HEAD_BRANCH' under the declared flow (spec 4.2). The declaration governs; supply an allowed base." >&2
exit 1
fi
# trc 2 cannot happen here (state=valid): 0 = allowed
else
EFFECTIVE_BASE="$DECL_TRUNK"
fi
elif [[ -z "$BASE_BRANCH" ]]; then
repo_decl_warn_absent_reversible "pr-create"
fi
# Add issue reference to body if provided
if [[ -n "$ISSUE" ]]; then
if [[ -n "$BODY" ]]; then
@@ -215,7 +261,7 @@ case "$PLATFORM" in
github)
CMD=(gh pr create --title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
[[ -n "$EFFECTIVE_BASE" ]] && CMD+=(--base "$EFFECTIVE_BASE")
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
@@ -240,7 +286,7 @@ case "$PLATFORM" in
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
[[ -n "$EFFECTIVE_BASE" ]] && CMD+=(--base "$EFFECTIVE_BASE")
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
# Handle labels for tea
@@ -137,9 +137,44 @@ HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=js
BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("baseRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
exit 1
# T51 WP5b: transition validation against the declaration (spec 4.1/4.2).
# Target branches are validated against the declaration, NEVER hardcoded;
# the legacy main/next check survives only for undeclared repos during the
# rollout window (4.3 irreversible class, loud warning).
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
source "$SCRIPT_DIR/repo-decl.sh"
repo_decl_load
else
DECL_STATE=absent; DECL_SCHEMA=""
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
repo_decl_report_invalid() { :; }
repo_decl_warn_absent_reversible() { :; }
repo_decl_warn_absent_irreversible() { :; }
repo_decl_remote_matches() { return 0; }
repo_decl_check_transition() { return 2; }
fi
if [[ "$DECL_STATE" == invalid ]]; then
repo_decl_report_invalid
fi
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
if ! repo_decl_remote_matches; then
echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to merge against the wrong forge. Fix the origin remote or the declaration." >&2
exit 1
fi
trc=0
repo_decl_check_transition "$HEAD_BRANCH" "$BASE_BRANCH" || trc=$?
if [[ "$trc" == 1 ]]; then
echo "Error: PR '$HEAD_BRANCH' -> '$BASE_BRANCH' is not a declared transition (flow=$DECL_FLOW, trunk=$DECL_TRUNK, release=$DECL_RELEASE; spec 4.2)." >&2
exit 1
fi
echo "repo-decl: transition OK under flow=$DECL_FLOW (trunk=$DECL_TRUNK release=$DECL_RELEASE)" >&2
else
repo_decl_warn_absent_irreversible "pr-merge"
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
exit 1
fi
fi
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
# repo-decl.sh — shared .mosaic/repo.json consumption for the git wrappers (T51 WP5b).
#
# Spec of record: docs/plans/2026-08-23_repo-structure-declaration.md (brain
# repo) sections 4 (consumption contract), 5.3 (normalization), 5.4
# (enforcement points), 1.2a (root anchoring). ALL consumers invoke the SAME
# WP1 validator (spec 5.1 — no in-process-only parsing of the declaration).
#
# Source this file, then call repo_decl_load once. It sets:
# DECL_STATE absent | invalid | valid
# DECL_FILE the declaration path that was inspected
# DECL_ERROR the validator's error line when DECL_STATE=invalid
# DECL_TRUNK / DECL_RELEASE / DECL_FLOW / DECL_REMOTE / DECL_POLICY /
# DECL_WT_ROOT / DECL_CLONE (populated only when DECL_STATE=valid)
# DECL_ORIGIN_N the normalized origin URL (when resolvable)
#
# Enforcement point 5.4(1): an invalid or unknown-version file counts as
# ABSENT for behavior, PLUS a loud error naming the file and the validator's
# key/reason — callers print DECL_ERROR (repo_decl_report_invalid) whenever
# they loaded something that failed validation; they do not silently ignore a
# broken file.
#
# Absence behavior (4.3) is the CALLER's policy (reversible vs irreversible;
# managed vs unmanaged — the adoption register is WP6, so during rollout every
# repo is unmanaged: warn + legacy). Helpers below provide the shared wordings.
#
# Root-dependent fields: consumers here read branch/flow/remote/policy only —
# NO path resolution happens in this library. The one helper that would
# resolve a host:/ path (repo_decl_path) fails closed while MOSAIC_HOST_ROOT
# is unset (1.2a: never guess a root), for any future caller that needs it.
#
# No output on success; diagnostics go to stderr.
REPO_DECL_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DECL_VALIDATOR="$REPO_DECL_SCRIPT_DIR/../structure/validate-repo-json.sh"
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
# Load and classify the declaration for the repo containing the current
# directory. Never fatal — classification is the product.
repo_decl_load() {
DECL_STATE=absent
DECL_FILE=""
DECL_ERROR=""
DECL_SCHEMA=""
DECL_TRUNK=""; DECL_RELEASE=""; DECL_FLOW=""; DECL_REMOTE=""
DECL_POLICY=""; DECL_WT_ROOT=""; DECL_CLONE=""
DECL_ORIGIN_N=""
local root
root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
repo_decl_warn "no git repository — declaration consumption skipped"
return 0
}
DECL_FILE="$root/.mosaic/repo.json"
[ -f "$DECL_FILE" ] || return 0
if [ ! -x "$REPO_DECL_VALIDATOR" ] && [ ! -f "$REPO_DECL_VALIDATOR" ]; then
# 5.1 mandates the shared validator; a missing validator is an
# infrastructure failure, not an absent declaration.
repo_decl_warn "validator not found at $REPO_DECL_VALIDATOR — treating declaration as invalid"
DECL_STATE=invalid
DECL_ERROR="VALIDATION_ERROR validator: the shared validator is missing"
return 0
fi
local vout
if ! vout="$("$REPO_DECL_VALIDATOR" "$DECL_FILE" --mode display 2>&1)"; then
DECL_STATE=invalid
DECL_ERROR="$(printf '%s\n' "$vout" | grep -m1 'VALIDATION_ERROR' || printf '%s\n' "$vout" | head -1)"
return 0
fi
# Valid: extract the consumed fields via the same python stdlib the
# ecosystem already uses. Field-level grammar was the validator's job.
eval "$(python3 - "$DECL_FILE" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
def q(k):
v = d.get(k, "")
return v if isinstance(v, str) else ""
sv = d.get("schema_version", 1)
print(f"DECL_SCHEMA={sv if isinstance(sv, int) and not isinstance(sv, bool) else 0!r}")
print(f"DECL_TRUNK={q('integration_trunk')!r}")
print(f"DECL_RELEASE={q('release_branch')!r}")
print(f"DECL_FLOW={q('flow')!r}")
print(f"DECL_REMOTE={q('canonical_remote')!r}")
print(f"DECL_POLICY={q('worktree_policy')!r}")
print(f"DECL_WT_ROOT={q('worktree_root')!r}")
print(f"DECL_CLONE={q('canonical_clone')!r}")
PY
)" || {
DECL_STATE=invalid
DECL_ERROR="VALIDATION_ERROR internal: field extraction failed"
return 0
}
DECL_STATE=valid
# 5.3: normalize origin once for remote comparisons (read callers warn,
# write callers refuse). An unresolvable origin is left empty — callers
# treat empty as "cannot compare" and act per their read/write policy.
local ourl
if ourl="$(git remote get-url origin 2>/dev/null)" && [ -n "$ourl" ]; then
DECL_ORIGIN_N="$("$REPO_DECL_VALIDATOR" --normalize-remote "$ourl" 2>/dev/null || true)"
fi
return 0
}
# The mandatory loud error for an invalid file (5.4 point 1). Callers invoke
# this whenever DECL_STATE=invalid, regardless of their proceed/refuse policy.
repo_decl_report_invalid() {
repo_decl_warn "declaration INVALID at $DECL_FILE$DECL_ERROR"
repo_decl_warn "treating the declaration as ABSENT (spec 5.4); legacy behavior follows"
}
# Shared absence wordings (4.3, rollout window: no adoption register yet, so
# every repo is unmanaged; warn + legacy per the Q-C ruling).
repo_decl_warn_absent_reversible() { # $1 = operation name
repo_decl_warn "no .mosaic/repo.json — $1 is unmanaged during rollout: legacy behavior, no declaration guarantees (spec 4.3)"
}
repo_decl_warn_absent_irreversible() { # $1 = operation name
repo_decl_warn "no .mosaic/repo.json — $1 proceeds on LEGACY assumptions during the rollout window; declaration-validated transitions unavailable (spec 4.3)"
}
# Remote comparison (5.3). rc 0 match/unknown, rc 1 mismatch.
repo_decl_remote_matches() {
[ "$DECL_STATE" = valid ] || return 0
[ -n "$DECL_ORIGIN_N" ] && [ -n "$DECL_REMOTE" ] || return 0
local want
want="$("$REPO_DECL_VALIDATOR" --normalize-remote "$DECL_REMOTE" 2>/dev/null || true)"
[ -n "$want" ] || return 0
[ "$DECL_ORIGIN_N" = "$want" ]
}
# Resolve a host:/-anchored declaration path (1.2a). Fails CLOSED while
# MOSAIC_HOST_ROOT is unset or empty — never guesses a root. No WP5b consumer
# calls this today; it exists so the first one that needs a path cannot
# silently guess.
repo_decl_path() { # $1 = host:/... value; prints the resolved absolute path
local v="${1:-}" root="${MOSAIC_HOST_ROOT:-}"
case "$v" in
host:/*) ;;
*) return 1 ;;
esac
if [ -z "$root" ]; then
repo_decl_warn "MOSAIC_HOST_ROOT is unset — refusing to resolve '$v' (spec 1.2a fail-closed; never guess a root)"
return 1
fi
printf '%s/%s\n' "${root%/}" "${v#host:/}"
}
# Transition validation (4.2: a CLI flag is input, not authority).
# rc 0 = allowed; rc 1 = forbidden (message on stderr); rc 2 = no valid
# declaration (caller applies its absence policy).
# flow=direct: base must be the trunk (trunk == release); head must
# differ from it.
# flow=trunk-release: feature->trunk allowed; trunk->release allowed (release
# promotion: head IS the trunk); anything else refused —
# feature->release explicitly REJECTED.
repo_decl_check_transition() { # $1 head, $2 base
[ "$DECL_STATE" = valid ] || return 2
local head="$1" base="$2"
if [ -z "$head" ] || [ -z "$base" ]; then
repo_decl_warn "transition check needs a head and a base (got head='$head' base='$base')"
return 1
fi
if [ "$head" = "$base" ]; then
repo_decl_warn "forbidden transition: head '$head' equals base '$base'"
return 1
fi
case "$DECL_FLOW" in
direct)
if [ "$base" = "$DECL_TRUNK" ]; then
return 0
fi
repo_decl_warn "forbidden transition (flow=direct): base must be the trunk '$DECL_TRUNK', got '$base'"
return 1
;;
trunk-release)
if [ "$base" = "$DECL_TRUNK" ] && [ "$head" != "$DECL_TRUNK" ] && [ "$head" != "$DECL_RELEASE" ]; then
return 0 # feature -> trunk
fi
if [ "$head" = "$DECL_TRUNK" ] && [ "$base" = "$DECL_RELEASE" ]; then
return 0 # release promotion: trunk -> release
fi
if [ "$base" = "$DECL_RELEASE" ] && [ "$head" != "$DECL_TRUNK" ]; then
repo_decl_warn "forbidden transition (flow=trunk-release): feature->release is REJECTED (head '$head' -> release '$DECL_RELEASE'); promote via $DECL_TRUNK"
return 1
fi
repo_decl_warn "forbidden transition (flow=trunk-release): '$head' -> '$base' is not a declared transition (feature->$DECL_TRUNK or $DECL_TRUNK->$DECL_RELEASE)"
return 1
;;
*)
repo_decl_warn "unknown declared flow '$DECL_FLOW'"
return 1
;;
esac
}
+616
View File
@@ -0,0 +1,616 @@
#!/usr/bin/env bash
# Regression harness for grant-reviewer.sh (#1415): org-team reviewer grant
# with fail-closed read-back verification.
#
# This harness models a REAL server: the curl stub keeps persistent team/
# member/repo state on disk, the POST actually CREATES and PERSISTS the team,
# the member/repo PUTs persist (except in the sabotage modes), and the
# read-back GETs answer from that same state. There is no fabricated record
# for the wrapper to "find" — verification passes only if the PUTs genuinely
# persisted what the read-back retrieves. It proves the wrapper:
# 1. creates the team with the EXACT reviewer payload (permission: read,
# units_map {repo.code: read, repo.issues: write, repo.pulls: write}) —
# the stub rejects any other payload;
# 2. is idempotent: an existing team is found by EXACT name (a decoy team
# whose name merely CONTAINS the wanted name is listed first and must
# not be matched) and no create POST is issued;
# 3. refuses to run against a GitHub-remoted repo (Gitea only);
# 4. refuses when the owner is not an organization;
# 5. maps HTTP 403 to "org admin required on <org>" and stops before any
# partial grant;
# 6. fails closed when the member PUT returns 204 without persisting (the
# #865 defect class: an exit code is not evidence of a durable write);
# 7. fails closed when the repo PUT returns 204 without persisting;
# 8. with GITEA_LOGIN set, performs EVERY request under that login's token
# (never the host default), and with an UNRESOLVABLE GITEA_LOGIN fails
# closed with ZERO API calls instead of downgrading;
# 9. never lets the bearer token ride in curl argv (curl --config only);
# 10. leaves no temp files behind on success or failure paths.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/grant-reviewer}"
REPO_DIR="$WORK_DIR/repo"
GH_REPO_DIR="$WORK_DIR/gh-repo"
BIN_DIR="$WORK_DIR/bin"
XDG_DIR="$WORK_DIR/xdg"
TEA_LOG="$WORK_DIR/tea.log"
CURL_LOG="$WORK_DIR/curl.log"
# Full curl argv per invocation — proves the bearer token never rides in argv.
CURL_ARGV_LOG="$WORK_DIR/curl-argv.log"
AUTH_LOG="$WORK_DIR/auth.log"
OUTPUT_FILE="$WORK_DIR/output.log"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
STATE_FILE="$WORK_DIR/grants.json"
PAYLOAD_VIOLATION_FILE="$WORK_DIR/payload-violation"
TMP_SCRATCH="$WORK_DIR/scratch"
HOME_DIR="$WORK_DIR/home"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$REPO_DIR" "$GH_REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" "$HOME_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
git -C "$GH_REPO_DIR" init -q
git -C "$GH_REPO_DIR" remote add origin https://github.com/someorg/somerepo.git
# HERMETICITY (#1007): get_gitea_token() step 0 resolves a per-agent identity
# from `git config --get mosaic.gitIdentity`, which on a provisioned seat is
# set GLOBALLY and leaks into this fresh repo, after which a REAL per-slot
# token is read from $HOME and the fixture credential is silently ignored. An
# empty repo-local value shadows the global one and reads back empty at rc=0.
# (The env-var route does NOT neutralize step 0's git-config read — but the
# run env below still pins MOSAIC_GIT_IDENTITY= empty so the ENV rung of the
# ladder cannot resolve either: `${MOSAIC_GIT_IDENTITY:-}` treats set-but-empty
# as unset.)
git -C "$REPO_DIR" config mosaic.gitIdentity ""
git -C "$GH_REPO_DIR" config mosaic.gitIdentity ""
ORG="mosaicstack"
REPO_SLUG="mosaicstack/stack"
API_ROOT="https://git.mosaicstack.dev/api/v1"
REVIEWER="rev-user"
TEAM_NAME="fleet-reviewers"
TEAM_ID=42
DECOY_TEAM_ID=99
DEFAULT_TOKEN="test-only-placeholder"
DEFAULT_IDENTITY="seat-default"
OVERRIDE_LOGIN="granter"
OVERRIDE_TOKEN="override-token-placeholder"
# tea config: the GITEA_LOGIN override login has its own host-bound token here.
mkdir -p "$XDG_DIR/tea"
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as handle:
handle.write("logins:\n")
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
handle.write(" url: https://git.mosaicstack.dev\n")
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
PY
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
import json
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
json.dump({
"gitea": {
"mosaicstack": {
"url": os.environ["CONFIGURED_GITEA_URL"],
"token": "test-only-placeholder",
}
}
}, credentials)
PY
# tea stub: grant-reviewer.sh must never shell out to tea at all.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$GRANT_REVIEWER_TEA_LOG"
echo "Unexpected tea command (grant-reviewer must not use tea): $*" >&2
exit 92
SH
chmod +x "$BIN_DIR/tea"
# curl stub: a small REST server backed by persistent on-disk grant state.
# GET /orgs/{org} -> org existence (404 in not-an-org mode)
# GET /orgs/{org}/teams/search -> teams from state (decoy always listed FIRST)
# POST /orgs/{org}/teams -> validate EXACT payload, CREATE + PERSIST
# PUT /teams/{id}/members/{user} -> 204; persists unless member-put-noop
# PUT /teams/{id}/repos/{org}/{repo} -> 204; persists unless repo-put-noop
# GET /teams/{id}/members/{user} -> answers from persisted state only
# GET /teams/{id}/repos/{org}/{repo} -> answers from persisted state only
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
# Record the FULL argv exactly as spawned, before consumption. The bearer token
# must NOT appear here — it is delivered via a curl --config file, so only the
# config file PATH may show up.
printf '%s\n' "$*" >> "$GRANT_REVIEWER_CURL_ARGV_LOG"
output_file=""
method="GET"
url=""
data=""
auth_token=""
config_file=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o) output_file="$2"; shift 2 ;;
-H)
[[ "$2" == Authorization:* ]] && auth_token="${2##* }"
shift 2 ;;
-K|--config) config_file="$2"; shift 2 ;;
-w) shift 2 ;;
-X) method="$2"; shift 2 ;;
-d|--data) data="$2"; shift 2 ;;
-s|-S|-sS) shift ;;
http://*|https://*) url="$1"; shift ;;
*) shift ;;
esac
done
# Resolve the bearer token from the curl --config file (its real, secure
# source). The config line is `header = "Authorization: token <value>"`.
if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then
config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)"
if [[ "$config_hdr" == *"token "* ]]; then
auth_token="${config_hdr##*token }"
auth_token="${auth_token%\"}"
fi
fi
path="${url%%\?*}"
printf '%s %s\n' "$method" "$url" >> "$GRANT_REVIEWER_CURL_LOG"
# Map the presented bearer token to the identity it authenticates as. Every
# request the wrapper makes must carry the SAME credential, so the identity
# recorded here reveals which credential actually performed each request.
acting_identity=""
case "$auth_token" in
"$GRANT_REVIEWER_DEFAULT_TOKEN") acting_identity="$GRANT_REVIEWER_DEFAULT_IDENTITY" ;;
"$GRANT_REVIEWER_OVERRIDE_TOKEN") acting_identity="$GRANT_REVIEWER_OVERRIDE_LOGIN" ;;
esac
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$GRANT_REVIEWER_AUTH_LOG"
write_response() {
local status="$1" body="$2"
[[ -n "$output_file" ]] || exit 96
printf '%s' "$body" > "$output_file"
printf '%s' "$status"
}
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
mode="$GRANT_REVIEWER_TEST_MODE"
org="$GRANT_REVIEWER_ORG"
api="$GRANT_REVIEWER_API_ROOT"
if [[ "$method" == "GET" && "$path" == "$api/orgs/$org" ]]; then
if [[ "$mode" == "not-an-org" ]]; then
write_response 404 '{"message":"not found"}'
else
write_response 200 "{\"username\":\"$org\"}"
fi
elif [[ "$method" == "GET" && "$path" == "$api/orgs/$org/teams/search" ]]; then
result=$(python3 - "$GRANT_REVIEWER_STATE" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
state = json.load(handle)
print(json.dumps({"ok": True, "data": state["teams"]}))
PY
)
write_response 200 "$result"
elif [[ "$method" == "POST" && "$path" == "$api/orgs/$org/teams" ]]; then
if [[ "$mode" == "create-403" ]]; then
write_response 403 '{"message":"forbidden"}'
exit 0
fi
result=$(GRANT_REVIEWER_DATA="$data" python3 - "$GRANT_REVIEWER_STATE" <<'PY'
import json
import os
import sys
payload = json.loads(os.environ["GRANT_REVIEWER_DATA"])
expected = {
"name": os.environ["GRANT_REVIEWER_TEAM_NAME"],
"description": "review seats: code read + issues/pulls write",
"permission": "read",
"includes_all_repositories": False,
"can_create_org_repo": False,
"units_map": {
"repo.code": "read",
"repo.issues": "write",
"repo.pulls": "write",
},
}
if payload != expected:
with open(os.environ["GRANT_REVIEWER_PAYLOAD_VIOLATION"], "w", encoding="utf-8") as handle:
json.dump({"got": payload, "expected": expected}, handle, indent=2)
print("422")
print(json.dumps({"message": "payload mismatch"}))
raise SystemExit(0)
state_path = sys.argv[1]
with open(state_path, encoding="utf-8") as handle:
state = json.load(handle)
team = {"id": int(os.environ["GRANT_REVIEWER_TEAM_ID"]), "name": payload["name"]}
state["teams"].append(team)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(state, handle)
print("201")
print(json.dumps(team))
PY
)
response_status="${result%%$'\n'*}"
response_body="${result#*$'\n'}"
write_response "$response_status" "$response_body"
elif [[ "$method" == "PUT" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/members/$GRANT_REVIEWER_REVIEWER" ]]; then
# Sabotage mode member-put-noop: 204 WITHOUT persisting — the exit-code lie.
if [[ "$mode" != "member-put-noop" ]]; then
python3 - "$GRANT_REVIEWER_STATE" <<'PY'
import json
import os
import sys
state_path = sys.argv[1]
with open(state_path, encoding="utf-8") as handle:
state = json.load(handle)
member = os.environ["GRANT_REVIEWER_REVIEWER"]
if member not in state["members"]:
state["members"].append(member)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(state, handle)
PY
fi
write_response 204 ''
elif [[ "$method" == "PUT" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/repos/$GRANT_REVIEWER_REPO_SLUG" ]]; then
# Sabotage mode repo-put-noop: 204 WITHOUT persisting.
if [[ "$mode" != "repo-put-noop" ]]; then
python3 - "$GRANT_REVIEWER_STATE" <<'PY'
import json
import os
import sys
state_path = sys.argv[1]
with open(state_path, encoding="utf-8") as handle:
state = json.load(handle)
slug = os.environ["GRANT_REVIEWER_REPO_SLUG"]
if slug not in state["repos"]:
state["repos"].append(slug)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(state, handle)
PY
fi
write_response 204 ''
elif [[ "$method" == "GET" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/members/$GRANT_REVIEWER_REVIEWER" ]]; then
if python3 - "$GRANT_REVIEWER_STATE" <<'PY'
import json
import os
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
state = json.load(handle)
raise SystemExit(0 if os.environ["GRANT_REVIEWER_REVIEWER"] in state["members"] else 1)
PY
then
write_response 200 "{\"login\":\"$GRANT_REVIEWER_REVIEWER\"}"
else
write_response 404 '{"message":"not a member"}'
fi
elif [[ "$method" == "GET" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/repos/$GRANT_REVIEWER_REPO_SLUG" ]]; then
if python3 - "$GRANT_REVIEWER_STATE" <<'PY'
import json
import os
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
state = json.load(handle)
raise SystemExit(0 if os.environ["GRANT_REVIEWER_REPO_SLUG"] in state["repos"] else 1)
PY
then
write_response 200 "{\"full_name\":\"$GRANT_REVIEWER_REPO_SLUG\"}"
else
write_response 404 '{"message":"repo not on team"}'
fi
else
echo "Unexpected curl request: $method $url" >&2
exit 97
fi
SH
chmod +x "$BIN_DIR/curl"
# Seed persistent server state for a mode: fresh (no team yet) or a pre-seeded
# team. The DECOY team — whose name CONTAINS the wanted name — is always listed
# FIRST, so a first-result or substring match would grab the wrong team.
seed_state() {
local seeded_team="$1"
GRANT_REVIEWER_SEEDED_TEAM="$seeded_team" GRANT_REVIEWER_TEAM_NAME="$TEAM_NAME" \
GRANT_REVIEWER_TEAM_ID="$TEAM_ID" GRANT_REVIEWER_DECOY_TEAM_ID="$DECOY_TEAM_ID" \
python3 - "$STATE_FILE" <<'PY'
import json
import os
import sys
wanted = os.environ["GRANT_REVIEWER_TEAM_NAME"]
teams = [{"id": int(os.environ["GRANT_REVIEWER_DECOY_TEAM_ID"]), "name": wanted + "-archive"}]
if os.environ["GRANT_REVIEWER_SEEDED_TEAM"] == "yes":
teams.append({"id": int(os.environ["GRANT_REVIEWER_TEAM_ID"]), "name": wanted})
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump({"teams": teams, "members": [], "repos": []}, handle)
PY
}
# run_grant <mode> <seeded-team yes|no> [extra env VAR=value ...] -- [wrapper args ...]
run_grant() {
local mode="$1" seeded="$2"
shift 2
local -a extra_env=()
while [[ $# -gt 0 && "$1" != "--" ]]; do
extra_env+=("$1")
shift
done
[[ $# -gt 0 ]] && shift
: > "$TEA_LOG"
: > "$CURL_LOG"
: > "$CURL_ARGV_LOG"
: > "$AUTH_LOG"
: > "$OUTPUT_FILE"
rm -f "$PAYLOAD_VIOLATION_FILE"
seed_state "$seeded"
(
cd "$RUN_REPO_DIR"
env \
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
HOME="$HOME_DIR" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" \
MOSAIC_GIT_IDENTITY= \
GITEA_LOGIN= \
GITEA_TOKEN= \
GITEA_URL= \
GRANT_REVIEWER_TEA_LOG="$TEA_LOG" \
GRANT_REVIEWER_CURL_LOG="$CURL_LOG" \
GRANT_REVIEWER_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
GRANT_REVIEWER_AUTH_LOG="$AUTH_LOG" \
GRANT_REVIEWER_STATE="$STATE_FILE" \
GRANT_REVIEWER_TEST_MODE="$mode" \
GRANT_REVIEWER_ORG="$ORG" \
GRANT_REVIEWER_API_ROOT="$API_ROOT" \
GRANT_REVIEWER_TEAM_NAME="$TEAM_NAME" \
GRANT_REVIEWER_TEAM_ID="$TEAM_ID" \
GRANT_REVIEWER_REVIEWER="$REVIEWER" \
GRANT_REVIEWER_REPO_SLUG="$REPO_SLUG" \
GRANT_REVIEWER_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
GRANT_REVIEWER_DEFAULT_IDENTITY="$DEFAULT_IDENTITY" \
GRANT_REVIEWER_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
GRANT_REVIEWER_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
GRANT_REVIEWER_PAYLOAD_VIOLATION="$PAYLOAD_VIOLATION_FILE" \
"${extra_env[@]}" \
"$SCRIPT_DIR/grant-reviewer.sh" -u "$REVIEWER" "$@"
) > "$OUTPUT_FILE" 2>&1
}
assert_no_temp_leak() {
local context="$1" leaked
# Includes the curl auth-config files (mosaic-gitea-auth-*), which carry the
# bearer token and must be unlinked on every exit path.
leaked=$(find "$TMP_SCRATCH" -type f \( -name 'mosaic-grant-reviewer-*' -o -name 'mosaic-gitea-auth-*' \) 2>/dev/null || true)
if [[ -n "$leaked" ]]; then
echo "FAIL: grant-reviewer temp files leaked ($context):" >&2
printf '%s\n' "$leaked" >&2
exit 1
fi
}
assert_token_not_in_argv() {
local context="$1"
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" "$CURL_ARGV_LOG"; then
echo "FAIL: a Gitea bearer token leaked into curl argv ($context)" >&2
exit 1
fi
if ! grep -q -- '--config' "$CURL_ARGV_LOG"; then
echo "FAIL: curl was not invoked with --config file auth ($context)" >&2
exit 1
fi
}
assert_no_payload_violation() {
local context="$1"
if [[ -f "$PAYLOAD_VIOLATION_FILE" ]]; then
echo "FAIL: team create payload deviated from the reviewer contract ($context):" >&2
cat "$PAYLOAD_VIOLATION_FILE" >&2
exit 1
fi
}
RUN_REPO_DIR="$REPO_DIR"
# Case 1: fresh grant — team absent, created with the exact reviewer payload,
# member + repo PUTs persist, both read-backs verify against server state.
run_grant normal no -- || {
echo "FAIL: fresh grant exited nonzero" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
grep -q "Created team '$TEAM_NAME' (id $TEAM_ID) on org '$ORG'" "$OUTPUT_FILE" || {
echo "FAIL: fresh grant did not create the team" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
grep -q "Granted: '$REVIEWER' is a member of team '$TEAM_NAME' (id $TEAM_ID) with access to '$REPO_SLUG'" "$OUTPUT_FILE" || {
echo "FAIL: fresh grant did not report a verified grant" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
assert_no_payload_violation "fresh"
assert_token_not_in_argv "fresh"
assert_no_temp_leak "fresh"
# The default path must have acted as the host-default identity on EVERY request.
if grep -qv " $DEFAULT_IDENTITY\$" "$AUTH_LOG"; then
echo "FAIL: fresh grant made a request under an unexpected identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
# grant-reviewer must never shell out to tea.
if [[ -s "$TEA_LOG" ]]; then
echo "FAIL: grant-reviewer invoked tea" >&2
cat "$TEA_LOG" >&2
exit 1
fi
# Case 2: idempotent — the team already exists. It must be found by EXACT name
# (the decoy is listed first), no create POST issued, and the decoy team must
# never be touched.
run_grant normal yes -- || {
echo "FAIL: idempotent grant exited nonzero" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
grep -q "Found existing team '$TEAM_NAME' (id $TEAM_ID) on org '$ORG'" "$OUTPUT_FILE" || {
echo "FAIL: idempotent grant did not find the existing team" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
grep -q "Granted: '$REVIEWER'" "$OUTPUT_FILE" || {
echo "FAIL: idempotent grant did not report a verified grant" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
if grep -q "^POST " "$CURL_LOG"; then
echo "FAIL: idempotent grant issued a create POST for an existing team" >&2
cat "$CURL_LOG" >&2
exit 1
fi
if grep -q "/teams/$DECOY_TEAM_ID/" "$CURL_LOG"; then
echo "FAIL: substring-named decoy team was operated on" >&2
cat "$CURL_LOG" >&2
exit 1
fi
assert_no_temp_leak "idempotent"
# Case 3: GITEA_LOGIN override — every request must carry the override login's
# token, never the host default credential.
run_grant normal no GITEA_LOGIN="$OVERRIDE_LOGIN" -- || {
echo "FAIL: GITEA_LOGIN override grant exited nonzero" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
grep -q "Granted: '$REVIEWER'" "$OUTPUT_FILE" || {
echo "FAIL: GITEA_LOGIN override grant did not succeed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
if grep -qv " $OVERRIDE_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: GITEA_LOGIN override made a request under a different identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
assert_token_not_in_argv "override"
assert_no_temp_leak "override"
# Case 4: unresolvable GITEA_LOGIN — fail closed BEFORE any API call; no
# downgrade to the host default identity.
if run_grant normal no GITEA_LOGIN="no-such-login" --; then
echo "FAIL: unresolvable GITEA_LOGIN did not fail" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
grep -q "refusing to fall back to the host default identity" "$OUTPUT_FILE" || {
echo "FAIL: unresolvable GITEA_LOGIN missing the fail-closed message" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
if [[ -s "$CURL_LOG" ]]; then
echo "FAIL: unresolvable GITEA_LOGIN still made API calls" >&2
cat "$CURL_LOG" >&2
exit 1
fi
assert_no_temp_leak "unresolvable-login"
# Case 5: GitHub-remoted repo — refuse before any API call.
RUN_REPO_DIR="$GH_REPO_DIR"
if run_grant normal no --; then
echo "FAIL: GitHub repo was not refused" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
grep -q "Gitea only" "$OUTPUT_FILE" || {
echo "FAIL: GitHub refusal missing the 'Gitea only' message" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
if [[ -s "$CURL_LOG" ]]; then
echo "FAIL: GitHub refusal still made API calls" >&2
cat "$CURL_LOG" >&2
exit 1
fi
RUN_REPO_DIR="$REPO_DIR"
# Case 6: owner is not an organization — clear refusal.
if run_grant not-an-org no --; then
echo "FAIL: non-org owner was not refused" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
grep -q "is not an organization" "$OUTPUT_FILE" || {
echo "FAIL: non-org refusal missing its message" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
assert_no_temp_leak "not-an-org"
# Case 7: HTTP 403 on team create — reported as an org-admin requirement, and
# the run stops before any member/repo PUT (no partial grant).
if run_grant create-403 no --; then
echo "FAIL: 403 on team create did not fail the run" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
grep -q "org admin required on '$ORG'" "$OUTPUT_FILE" || {
echo "FAIL: 403 was not mapped to the org-admin message" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
if grep -q "^PUT " "$CURL_LOG"; then
echo "FAIL: run continued into PUTs after a 403 (partial grant)" >&2
cat "$CURL_LOG" >&2
exit 1
fi
assert_no_temp_leak "create-403"
# Cases 8-9: the exit-code lie — a PUT answers 204 without persisting. The
# read-back must fail closed; no success line may appear.
for noop_mode in member-put-noop repo-put-noop; do
if run_grant "$noop_mode" no --; then
echo "FAIL: $noop_mode was reported as success" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
grep -q "NOT verified" "$OUTPUT_FILE" || {
echo "FAIL: $noop_mode missing the fail-closed verification message" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
if grep -q "^Granted:" "$OUTPUT_FILE"; then
echo "FAIL: $noop_mode still printed the success line" >&2
exit 1
fi
assert_no_temp_leak "$noop_mode"
done
echo "grant-reviewer.sh org-team grant + fail-closed read-back regression passed"
@@ -0,0 +1,333 @@
#!/usr/bin/env bash
# test-repo-decl-consumption.sh — hermetic declaration-consumption suite for the
# git wrappers (T51 WP5b).
#
# Spec of record (brain repo): docs/plans/2026-08-23_repo-structure-declaration.md
# sections 4 (consumption), 5.3 (normalization), 5.4 (hostile-input classes),
# 1.2a (root anchoring). Covers every §5.4 class applicable to consumed fields
# plus the per-tool behaviors (base precedence, transition validation, remote
# fail-closed, absence policy, route context, staged worktree rule).
#
# Red-first usage: WP5B_TOOLS=<dir with PRE-change tool copies> bash $0
# exits nonzero — the declaration-driven arms fail against tools that predate
# the change (evidence captured in the WP5b report).
#
# Hermetic: scratch repos under $TMPDIR, PATH-stubbed curl, sandboxed HOME; no
# network, no live forge, no writes outside the sandbox.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOOLS_SRC="${WP5B_TOOLS:-$SCRIPT_DIR}"
TROOT="${TMPDIR:-/tmp}"
PASS=0 FAIL=0 FAILED_CASES=""
FIXTURES=()
cleanup_all() { local f; for f in "${FIXTURES[@]:-}"; do rm -rf -- "$f"; done; }
trap cleanup_all EXIT INT TERM
ok() { PASS=$((PASS + 1)); }
bad() { FAIL=$((FAIL + 1)); FAILED_CASES="$FAILED_CASES $1"; printf 'FAIL: %s\n' "$1" >&2; }
assert_rc() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected rc=$e got rc=$a)"; }
assert_eq() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected [$e] got [$a])"; }
assert_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) ok ;; *) bad "$d (missing [$n])" ;; esac; }
assert_not_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) bad "$d (unexpected [$n])" ;; *) ok ;; esac; }
assert_count() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected $e got $a)"; }
new_sb() { SB="$(mktemp -d "$TROOT/wp5b-test.XXXXXX")"; FIXTURES+=("$SB"); }
# A fixture repo with the (possibly pre-change) tools + validator installed.
# $1 dir, stdin = declaration JSON ("" = none), $2 = origin url ("" = none)
mkrepo() {
local d="$1" decl_origin="${2:-}"
mkdir -p "$d/tools/git" "$d/tools/structure" "$d/home"
cp "$TOOLS_SRC/repo-decl.sh" "$d/tools/git/" 2>/dev/null || true
cp "$TOOLS_SRC/pr-create.sh" "$TOOLS_SRC/pr-merge.sh" "$TOOLS_SRC/mosaic-worktree.sh" "$TOOLS_SRC/ci-queue-wait.sh" "$TOOLS_SRC/detect-platform.sh" "$d/tools/git/" 2>/dev/null || true
cp "$SCRIPT_DIR/../structure/validate-repo-json.sh" "$d/tools/structure/"
git -C "$d" init -q -b feature/x
git -C "$d" config user.name fixture; git -C "$d" config user.email fixture@test
mkdir -p "$d/.mosaic" "$d/sites"
printf 'base\n' > "$d/f"; git -C "$d" add -A; git -C "$d" commit -q -m base
[ -n "$decl_origin" ] && git -C "$d" remote add origin "$decl_origin"
cat > "$d/.mosaic/repo.json"
# whitespace-only input (the absent-decl arms use <<< "") must mean ABSENT,
# not an invalid-file fixture: strip it to no file.
grep -q '[^[:space:]]' "$d/.mosaic/repo.json" 2>/dev/null || rm -f "$d/.mosaic/repo.json"
}
# The canonical v2 declaration used by default (custom branch names prove the
# tools never hardcode): trunk=dev-trunk release=prod-rel flow=trunk-release.
DECL_TR='{
"schema_version": 2,
"integration_trunk": "dev-trunk",
"release_branch": "prod-rel",
"flow": "trunk-release",
"canonical_remote": "https://git.example.test/acme/widgets",
"canonical_clone": "host:/src/widgets",
"worktree_root": "host:/src/widgets-worktrees",
"worktree_policy": "orchestrator-precreated"
}'
decl_direct() { printf '{\n "schema_version": 2,\n "integration_trunk": "mainline",\n "release_branch": "mainline",\n "flow": "direct",\n "canonical_remote": "https://git.example.test/acme/widgets",\n "canonical_clone": "host:/src/widgets"\n}\n'; }
load_decl_in() { # $1 dir -> runs repo_decl_load in a subshell, prints STATE etc.
( cd "$1" && source "$1/tools/git/repo-decl.sh" && repo_decl_load \
&& printf 'STATE=%s SCHEMA=%s TRUNK=%s RELEASE=%s FLOW=%s ERROR=%s\n' \
"$DECL_STATE" "${DECL_SCHEMA:-}" "${DECL_TRUNK:-}" "${DECL_RELEASE:-}" "${DECL_FLOW:-}" "${DECL_ERROR:-}" )
}
echo "== A. lib classification + hostile inputs (spec 5.4 classes) =="
new_sb; mkrepo "$SB/r1" <<< "$DECL_TR" "https://git.example.test/acme/widgets.git"
A="$(load_decl_in "$SB/r1")"
assert_contains "A1 valid v2 state" "$A" "STATE=valid"
assert_contains "A1 trunk" "$A" "TRUNK=dev-trunk"
assert_contains "A1 release" "$A" "RELEASE=prod-rel"
assert_contains "A1 flow" "$A" "FLOW=trunk-release"
assert_contains "A1 schema" "$A" "SCHEMA=2"
new_sb; mkrepo "$SB/r2" <<< "" "https://git.example.test/acme/widgets"
A="$(load_decl_in "$SB/r2")"
assert_contains "A2 missing file = absent" "$A" "STATE=absent"
new_sb; mkrepo "$SB/r3" <<< '{ not json'
A="$(load_decl_in "$SB/r3")"
assert_contains "A3 malformed JSON = invalid" "$A" "STATE=invalid"
assert_contains "A3 error names the failure" "$A" "VALIDATION_ERROR"
new_sb; mkrepo "$SB/r4" <<< '{"schema_version": 99, "integration_trunk": "x", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b"}'
A="$(load_decl_in "$SB/r4")"
assert_contains "A4 unknown schema_version = invalid" "$A" "STATE=invalid"
new_sb; mkrepo "$SB/r5" <<< '{"integration_trunk": "x", "release_branch": "y"}'
A="$(load_decl_in "$SB/r5")"
assert_contains "A5 v1 validates" "$A" "STATE=valid"
assert_contains "A5 v1 schema recorded" "$A" "SCHEMA=1"
new_sb; mkrepo "$SB/r6" <<< '{"schema_version": 2, "integration_trunk": "x", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b", "surprise": 1}'
A="$(load_decl_in "$SB/r6")"
assert_contains "A6 unknown top-level key = invalid" "$A" "STATE=invalid"
new_sb; mkrepo "$SB/r7" <<< '{"schema_version": 2, "integration_trunk": "bad..name", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b"}'
A="$(load_decl_in "$SB/r7")"
assert_contains "A7 bad ref name = invalid" "$A" "STATE=invalid"
new_sb; mkrepo "$SB/r8" <<< '{"schema_version": 2, "integration_trunk": "a", "release_branch": "b", "flow": "direct", "canonical_remote": "https://a/b"}'
A="$(load_decl_in "$SB/r8")"
assert_contains "A8 cross-field violation = invalid" "$A" "STATE=invalid"
new_sb; mkrepo "$SB/r9" <<< '{"schema_version": 2, "integration_trunk": "a", "release_branch": "b", "flow": "trunk-release", "canonical_remote": "https://user:pw@a/b"}'
A="$(load_decl_in "$SB/r9")"
assert_contains "A9 userinfo URL = invalid" "$A" "STATE=invalid"
echo "== A2. transitions + remote + path anchoring =="
new_sb; mkrepo "$SB/t1" <<< "$DECL_TR"
T=(); rc=0
T_out="$( cd "$SB/t1" && source tools/git/repo-decl.sh && repo_decl_load
repo_decl_check_transition feat dev-trunk && echo "feat->trunk:ALLOWED"
repo_decl_check_transition dev-trunk prod-rel && echo "trunk->rel:ALLOWED"
repo_decl_check_transition feat prod-rel || echo "feat->rel:REFUSED"
repo_decl_check_transition other other || echo "same:REFUSED"
repo_decl_check_transition feat elsewhere || echo "arbitrary:REFUSED" )"
assert_contains "T1 feature->trunk allowed" "$T_out" "feat->trunk:ALLOWED"
assert_contains "T1 trunk->release allowed" "$T_out" "trunk->rel:ALLOWED"
assert_contains "T1 feature->release refused" "$T_out" "feat->rel:REFUSED"
assert_contains "T1 head==base refused" "$T_out" "same:REFUSED"
assert_contains "T1 arbitrary target refused" "$T_out" "arbitrary:REFUSED"
new_sb; mkrepo "$SB/t2" <<< "$(decl_direct)"
T_out="$( cd "$SB/t2" && source tools/git/repo-decl.sh && repo_decl_load
repo_decl_check_transition feat mainline && echo "direct-ok:ALLOWED"
repo_decl_check_transition feat other || echo "direct-other:REFUSED" )"
assert_contains "T2 direct feature->trunk allowed" "$T_out" "direct-ok:ALLOWED"
assert_contains "T2 direct other base refused" "$T_out" "direct-other:REFUSED"
new_sb; mkrepo "$SB/t3" <<< "$DECL_TR" "https://Git.Example.Test/acme/widgets.git/"
M="$( cd "$SB/t3" && source tools/git/repo-decl.sh && repo_decl_load && repo_decl_remote_matches && echo MATCH )"
assert_contains "T3 normalization: .git/case differences still MATCH" "$M" "MATCH"
new_sb; mkrepo "$SB/t4" <<< "$DECL_TR" "https://git.example.test/acme/OTHER"
M="$( cd "$SB/t4" && source tools/git/repo-decl.sh && repo_decl_load && { repo_decl_remote_matches && echo MATCH; } || echo MISMATCH )"
assert_contains "T4 remote mismatch detected" "$M" "MISMATCH"
new_sb; mkrepo "$SB/t5" <<< "$DECL_TR"
P="$( cd "$SB/t5" && source tools/git/repo-decl.sh && { repo_decl_path "host:/src/x" 2>/dev/null && echo RESOLVED; } || echo FAILCLOSED )"
assert_contains "T5 host:/ resolution fails closed (root unset)" "$P" "FAILCLOSED"
echo "== B. pr-create consumption =="
mkpr() { # $1 dir: install the curl stub + run env; sets PR_RC/PR_OUT/PR_ERR/PR_PAYLOAD
mkdir -p "$1/stub"
cat > "$1/stub/curl" <<'STUB'
#!/usr/bin/env bash
url="${*: -1}"
printf 'curl %s\n' "$*" >> "${STUB_DIR:?}/calls.log"
case "$url" in
*/api/v1/repos/acme/widgets) printf '%s\n' '{"default_branch":"forge-default"}'; exit 0 ;;
*/pulls)
while [[ $# -gt 0 ]]; do
case "$1" in -d) printf '%s' "$2" > "${STUB_DIR:?}/payload.json"; shift 2 ;; *) shift ;; esac
done
printf '%s\n' '{"number":42}'; exit 0 ;;
*) printf '%s\n' '{}'; exit 0 ;;
esac
STUB
chmod +x "$1/stub/curl"
}
run_pr() { # $1 dir, rest args -> pr-create
local prdir="$1"; shift
PR_RC=0
PR_OUT="$(cd "$prdir" && env -i PATH="$prdir/stub:/usr/bin:/bin" HOME="$prdir/home" \
GITEA_TOKEN=stub-token STUB_DIR="$prdir" \
bash "$prdir/tools/git/pr-create.sh" "$@" < /dev/null 2>"$prdir/err.txt")" || PR_RC=$?
PR_ERR="$(cat "$prdir/err.txt")"
PR_PAYLOAD="$(cat "$prdir/payload.json" 2>/dev/null || true)"
PR_GETS="$(grep -c 'repos/acme/widgets$' "$prdir/calls.log" 2>/dev/null || true)"; PR_GETS="${PR_GETS:-0}"
PR_POSTS="$(grep -c '/pulls$' "$prdir/calls.log" 2>/dev/null || true)"; PR_POSTS="${PR_POSTS:-0}"
: > "$prdir/calls.log" 2>/dev/null || true
rm -f "$prdir/payload.json"
}
new_sb; mkrepo "$SB/b1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"; mkpr "$SB/b1"
git -C "$SB/b1" checkout -q -b feature/x 2>/dev/null || true
run_pr "$SB/b1" -t "T"
assert_rc "B1 declared trunk base rc 0" 0 "$PR_RC"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B1 payload base = declared trunk (no -B)" "dev-trunk" "$base"
assert_count "B1 zero repo GETs (declared trunk consulted, not the forge default)" 0 "$PR_GETS"
run_pr "$SB/b1" -t "T" -B prod-rel
assert_rc "B2 -B feature->release REFUSED (4.2)" 1 "$PR_RC"
assert_contains "B2 names the transition rule" "$PR_ERR" "not an allowed transition"
assert_count "B2 no POST issued" 0 "$PR_POSTS"
run_pr "$SB/b1" -t "T" -B dev-trunk
assert_rc "B3 -B feature->trunk allowed" 0 "$PR_RC"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B3 payload base = explicit allowed -B" "dev-trunk" "$base"
run_pr "$SB/b1" -t "T" -B dev-trunk --head dev-trunk
assert_rc "B4 -B trunk->trunk (head==base) refused" 1 "$PR_RC"
new_sb; mkrepo "$SB/b5" <<< "$DECL_TR" "https://git.example.test/acme/wrong"; mkpr "$SB/b5"
run_pr "$SB/b5" -t "T"
assert_rc "B5 remote mismatch on write path refuses" 1 "$PR_RC"
assert_contains "B5 names the 5.3 rule" "$PR_ERR" "canonical_remote"
assert_count "B5 no POST" 0 "$PR_POSTS"
new_sb; mkrepo "$SB/b6" <<< "" "https://git.example.test/acme/widgets"; mkpr "$SB/b6"
run_pr "$SB/b6" -t "T"
assert_rc "B6 absent decl: legacy forge default rc 0" 0 "$PR_RC"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B6 payload base = forge default" "forge-default" "$base"
assert_count "B6 repo GET performed (WP5a floor preserved)" 1 "$PR_GETS"
assert_contains "B6 absence warning present" "$PR_ERR" "unmanaged during rollout"
new_sb; mkrepo "$SB/b7" <<< '{ not json' "https://git.example.test/acme/widgets"; mkpr "$SB/b7"
run_pr "$SB/b7" -t "T"
assert_rc "B7 invalid decl: loud report + legacy proceed" 0 "$PR_RC"
assert_contains "B7 validation error reported" "$PR_ERR" "declaration INVALID"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B7 legacy forge default used" "forge-default" "$base"
new_sb; mkrepo "$SB/b8" <<< '{"integration_trunk": "x", "release_branch": "y"}' "https://git.example.test/acme/widgets"; mkpr "$SB/b8"
run_pr "$SB/b8" -t "T"
assert_rc "B8 v1 decl: legacy proceed rc 0" 0 "$PR_RC"
assert_contains "B8 v1 note present" "$PR_ERR" "no consumable flow/trunk fields"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B8 legacy forge default used" "forge-default" "$base"
echo "== C. pr-merge transition validation (no hardcoded targets) =="
mkmerge() { # $1 dir, $2 base, $3 head -> stubs pr-metadata; runs pr-merge; sets M_RC/M_OUT/M_ERR
cp "$SCRIPT_DIR/pr-merge.sh" "$1/tools/git/pr-merge.sh" 2>/dev/null || true
cat > "$1/tools/git/pr-metadata.sh" <<EOF
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"$2","headRefName":"$3","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"acme/widgets","title":"t","author":{"login":"a"}}'
EOF
chmod +x "$1/tools/git/pr-metadata.sh"
cat > "$1/tools/git/ci-queue-wait.sh" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
chmod +x "$1/tools/git/ci-queue-wait.sh"
mkdir -p "$1/stub"
printf '#!/usr/bin/env bash\nexit 0\n' > "$1/stub/curl"; chmod +x "$1/stub/curl"
M_RC=0
M_OUT="$(cd "$1" && env -i PATH="$1/stub:/usr/bin:/bin" HOME="$1/home" GITEA_TOKEN=stub-token \
bash "$1/tools/git/pr-merge.sh" -n 7 --dry-run < /dev/null 2>"$1/merr.txt")" || M_RC=$?
M_ERR="$(cat "$1/merr.txt")"
}
new_sb; mkrepo "$SB/c1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"
mkmerge "$SB/c1" dev-trunk feature/x
assert_rc "C1 feature->trunk under decl (custom trunk name, no hardcode)" 0 "$M_RC"
assert_contains "C1 transition context printed" "$M_ERR" "transition OK under flow=trunk-release"
mkmerge "$SB/c1" prod-rel feature/x
assert_rc "C2 feature->release REJECTED under decl" 1 "$M_RC"
assert_contains "C2 names the declared transition rule" "$M_ERR" "not a declared transition"
mkmerge "$SB/c1" prod-rel dev-trunk
assert_rc "C3 trunk->release promotion allowed" 0 "$M_RC"
new_sb; mkrepo "$SB/c4" <<< "" "https://git.example.test/acme/widgets"
mkmerge "$SB/c4" main feature/x
assert_rc "C4 absent decl: legacy main/next check still enforced (main ok)" 0 "$M_RC"
assert_contains "C4 legacy warning present" "$M_ERR" "LEGACY assumptions"
mkmerge "$SB/c4" trunk-x feature/x
assert_rc "C4 absent decl: unknown target rejected by legacy check" 1 "$M_RC"
new_sb; mkrepo "$SB/c5" <<< "$DECL_TR" "https://git.example.test/acme/wrong"
mkmerge "$SB/c5" dev-trunk feature/x
assert_rc "C5 remote mismatch refuses the merge" 1 "$M_RC"
assert_contains "C5 names 5.3" "$M_ERR" "canonical_remote"
echo "== D. mosaic-worktree staged rule (4.4/4.5) =="
mkwt() { # $1 dir: home outside the repo so derivation passes assert_not_home
mv "$1/home" "$SB/wthome" 2>/dev/null || true
}
new_sb; mkrepo "$SB/d1" <<< '{ not json'; mkwt "$SB/d1"
WT_RC=0
WT_OUT="$(cd "$SB/d1" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
bash "$SB/d1/tools/git/mosaic-worktree.sh" new feat2 < /dev/null 2>"$SB/d1/wterr.txt")" || WT_RC=$?
assert_rc "D1 invalid decl fails branch-creation loud" 1 "$WT_RC"
assert_contains "D1 names the invalid declaration" "$(cat "$SB/d1/wterr.txt")" "INVALID"
new_sb; mkrepo "$SB/d2" <<< ""; mkwt "$SB/d2"
WT_RC=0
WT_OUT="$(cd "$SB/d2" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
bash "$SB/d2/tools/git/mosaic-worktree.sh" new feat3 < /dev/null 2>"$SB/d2/wterr.txt")" || WT_RC=$?
assert_rc "D2 absent decl: warn + proceed" 0 "$WT_RC"
assert_contains "D2 loud warning present" "$(cat "$SB/d2/wterr.txt")" "LEGACY assumptions"
new_sb; mkrepo "$SB/d3" <<< "$DECL_TR"; mkwt "$SB/d3"
WT_RC=0
WT_OUT="$(cd "$SB/d3" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
bash "$SB/d3/tools/git/mosaic-worktree.sh" new feat4 < /dev/null 2>"$SB/d3/wterr.txt")" || WT_RC=$?
assert_rc "D3 valid decl: policy advisory + proceed" 0 "$WT_RC"
assert_contains "D3 precreated policy note" "$(cat "$SB/d3/wterr.txt")" "orchestrator-precreated"
assert_contains "D3 placement stays derived (no decl path used)" "$(cat "$SB/d3/wterr.txt")" "TRANSITIONAL"
echo "== E. ci-queue-wait route context (C4: context only, never a gate) =="
new_sb; mkrepo "$SB/e1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"
git -C "$SB/e1" checkout -q -b dev-trunk 2>/dev/null || { git -C "$SB/e1" branch -q dev-trunk; git -C "$SB/e1" checkout -q dev-trunk; }
E_RC=0
E_OUT="$(cd "$SB/e1" && env -i PATH="/usr/bin:/bin" HOME="$SB/e1/home" \
bash "$SB/e1/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e1/eerr.txt")" || E_RC=$?
E_ERR="$(cat "$SB/e1/eerr.txt")"
assert_contains "E1 route context names trunk head" "$E_ERR" "'dev-trunk' is a trunk (integration head) head"
assert_contains "E1 context names the flow" "$E_ERR" "flow=trunk-release"
new_sb; mkrepo "$SB/e2" <<< "" "https://git.example.test/acme/widgets"
E_RC=0
E_OUT="$(cd "$SB/e2" && env -i PATH="/usr/bin:/bin" HOME="$SB/e2/home" \
bash "$SB/e2/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e2/eerr.txt")" || E_RC=$?
assert_not_contains "E2 absence is SILENT for the guard (N4)" "$(cat "$SB/e2/eerr.txt")" "repo-decl"
cleanup_all
assert_count "final: zero scratch residue" 0 "$(ls -d "$TROOT"/wp5b-test.* 2>/dev/null | wc -l | tr -d ' ')"
echo
echo "pass=$PASS fail=$FAIL"
if [ "$FAIL" -gt 0 ]; then
echo "FAILED CASES:$FAILED_CASES"
exit 1
fi
echo "ALL GREEN"
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
+10 -4
View File
@@ -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);