Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c39a694848 | ||
|
|
c948452cf7 | ||
|
|
748b4c1fe5 |
+4
-6
@@ -38,12 +38,10 @@ when:
|
|||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
|
||||||
# Turbo remote cache (turbo.mosaicstack.dev) is wired in publish.yml via the
|
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||||
# org-level Woodpecker secret `turbo_token` (events: push/tag/cron/manual/
|
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||||
# deployment — never pull_request). This PR pipeline deliberately gets no
|
# This avoids from_secret which is blocked on pull_request events.
|
||||||
# remote-cache credentials: an untrusted PR must not be able to write to (or
|
# If the env vars aren't set, turbo falls back to local cache only.
|
||||||
# poison) the shared cache. Without TURBO_* env vars turbo falls back to
|
|
||||||
# local cache only, which is the intended behavior here.
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
install:
|
install:
|
||||||
|
|||||||
+14
-41
@@ -32,11 +32,6 @@ variables:
|
|||||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||||
# the separate `event: tag` entry.)
|
# the separate `event: tag` entry.)
|
||||||
# #1407: ONE shared anchor for all three image steps. A second main-only
|
|
||||||
# anchor previously gated build-web/build-appservice, so next-lane pushes
|
|
||||||
# published gateway sha images with no web/appservice counterpart — no
|
|
||||||
# sha-parity set existed for next-lane containerized deploys. Every image
|
|
||||||
# step now builds on next too (sha-only destinations, enforced per step).
|
|
||||||
- &image_build_when
|
- &image_build_when
|
||||||
- event: tag
|
- event: tag
|
||||||
- event: [push, manual]
|
- event: [push, manual]
|
||||||
@@ -49,6 +44,16 @@ variables:
|
|||||||
- '.woodpecker/**'
|
- '.woodpecker/**'
|
||||||
- event: [push, manual]
|
- event: [push, manual]
|
||||||
branch: next
|
branch: next
|
||||||
|
- &main_image_build_when
|
||||||
|
- event: tag
|
||||||
|
- event: [push, manual]
|
||||||
|
branch: main
|
||||||
|
path:
|
||||||
|
exclude:
|
||||||
|
- 'packages/mosaic/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- '**/*.md'
|
||||||
|
- '.woodpecker/**'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- branch: [main, next]
|
- branch: [main, next]
|
||||||
@@ -68,13 +73,6 @@ steps:
|
|||||||
# being empty) and on any incomplete verification.
|
# being empty) and on any incomplete verification.
|
||||||
verify:
|
verify:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
|
|
||||||
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
||||||
@@ -110,13 +108,6 @@ steps:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
|
|
||||||
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- pnpm build
|
- pnpm build
|
||||||
@@ -469,7 +460,7 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: REGISTRY_USERNAME
|
||||||
@@ -483,17 +474,8 @@ steps:
|
|||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
- |
|
- |
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[publish] next appservice publish is sha-only"
|
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
||||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||||
@@ -513,7 +495,7 @@ steps:
|
|||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: REGISTRY_USERNAME
|
||||||
@@ -527,17 +509,8 @@ steps:
|
|||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
- |
|
- |
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[publish] next web publish is sha-only"
|
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||||
|
|||||||
@@ -14,16 +14,10 @@ import { mountMcpHandler } from './mcp/mcp.controller.js';
|
|||||||
import { McpService } from './mcp/mcp.service.js';
|
import { McpService } from './mcp/mcp.service.js';
|
||||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||||
import { resolveGatewayConfigPath } from './env.js';
|
import { resolveGatewayConfigPath } from './env.js';
|
||||||
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
|
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger('Bootstrap');
|
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']) {
|
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||||
throw new Error('BETTER_AUTH_SECRET is required');
|
throw new Error('BETTER_AUTH_SECRET is required');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { type CanActivate, type ExecutionContext, type INestApplication } from '@nestjs/common';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { TeamsController } from './teams.controller.js';
|
|
||||||
import { TeamsService } from './teams.service.js';
|
|
||||||
|
|
||||||
const teamAlpha = { id: 'team-alpha', name: 'Alpha' };
|
|
||||||
const teamBeta = { id: 'team-beta', name: 'Beta' };
|
|
||||||
|
|
||||||
// user-1 is a member of team-alpha only; admin-1 has role admin.
|
|
||||||
let currentUser: { id: string; role?: string } = { id: 'user-1' };
|
|
||||||
|
|
||||||
const teamsServiceMock = {
|
|
||||||
findAll: vi.fn(() => Promise.resolve([teamAlpha, teamBeta])),
|
|
||||||
findAllForUser: vi.fn((userId: string) =>
|
|
||||||
Promise.resolve(userId === 'user-1' ? [teamAlpha] : []),
|
|
||||||
),
|
|
||||||
findById: vi.fn((id: string) => Promise.resolve([teamAlpha, teamBeta].find((t) => t.id === id))),
|
|
||||||
listMembers: vi.fn(() => Promise.resolve([{ teamId: 'team-alpha', userId: 'user-1' }])),
|
|
||||||
isMember: vi.fn((teamId: string, userId: string) =>
|
|
||||||
Promise.resolve(teamId === 'team-alpha' && userId === 'user-1'),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const authGuard: CanActivate = {
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const requestContext = context
|
|
||||||
.switchToHttp()
|
|
||||||
.getRequest<{ user?: { id: string; role?: string } }>();
|
|
||||||
requestContext.user = currentUser;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('teams endpoints are scoped to membership', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
controllers: [TeamsController],
|
|
||||||
providers: [{ provide: TeamsService, useValue: teamsServiceMock }],
|
|
||||||
})
|
|
||||||
.overrideGuard(AuthGuard)
|
|
||||||
.useValue(authGuard)
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
await app.init();
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
currentUser = { id: 'user-1' };
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams returns only the teams the user belongs to', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual([teamAlpha]);
|
|
||||||
expect(teamsServiceMock.findAll).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams returns every team for an admin', async () => {
|
|
||||||
currentUser = { id: 'admin-1', role: 'admin' };
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual([teamAlpha, teamBeta]);
|
|
||||||
expect(teamsServiceMock.findAllForUser).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams/:teamId returns 403 for a non-member', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta');
|
|
||||||
expect(response.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams/:teamId returns 404 for a missing team', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams/team-missing');
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams/:teamId returns the team for a member', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha');
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual(teamAlpha);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams/:teamId/members returns 403 for a non-member and members for a member', async () => {
|
|
||||||
const denied = await request(app.getHttpServer()).get('/api/teams/team-beta/members');
|
|
||||||
expect(denied.status).toBe(403);
|
|
||||||
expect(teamsServiceMock.listMembers).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
const allowed = await request(app.getHttpServer()).get('/api/teams/team-alpha/members');
|
|
||||||
expect(allowed.status).toBe(200);
|
|
||||||
expect(allowed.body).toEqual([{ teamId: 'team-alpha', userId: 'user-1' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams/:teamId/members/:userId allows a self-lookup on any team', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-1');
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual({ isMember: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams/:teamId/members/:userId denies looking up another user on a foreign team', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-2');
|
|
||||||
expect(response.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('an admin can look up any membership', async () => {
|
|
||||||
currentUser = { id: 'admin-1', role: 'admin' };
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha/members/user-1');
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual({ isMember: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,68 +1,30 @@
|
|||||||
import {
|
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
ForbiddenException,
|
|
||||||
Get,
|
|
||||||
NotFoundException,
|
|
||||||
Param,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
import { TeamsService } from './teams.service.js';
|
import { TeamsService } from './teams.service.js';
|
||||||
|
|
||||||
type RequestUser = { id: string; role?: string };
|
|
||||||
|
|
||||||
@Controller('api/teams')
|
@Controller('api/teams')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class TeamsController {
|
export class TeamsController {
|
||||||
constructor(private readonly teams: TeamsService) {}
|
constructor(private readonly teams: TeamsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@CurrentUser() user: RequestUser) {
|
async list() {
|
||||||
if (user.role === 'admin') {
|
return this.teams.findAll();
|
||||||
return this.teams.findAll();
|
|
||||||
}
|
|
||||||
return this.teams.findAllForUser(user.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId')
|
@Get(':teamId')
|
||||||
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
async findOne(@Param('teamId') teamId: string) {
|
||||||
return this.getAccessibleTeam(teamId, user);
|
return this.teams.findById(teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId/members')
|
@Get(':teamId/members')
|
||||||
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
async listMembers(@Param('teamId') teamId: string) {
|
||||||
await this.getAccessibleTeam(teamId, user);
|
|
||||||
return this.teams.listMembers(teamId);
|
return this.teams.listMembers(teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId/members/:userId')
|
@Get(':teamId/members/:userId')
|
||||||
async checkMembership(
|
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||||
@Param('teamId') teamId: string,
|
|
||||||
@Param('userId') userId: string,
|
|
||||||
@CurrentUser() user: RequestUser,
|
|
||||||
) {
|
|
||||||
// A user may always ask about their own membership; anything else is
|
|
||||||
// team-scoped like the other routes.
|
|
||||||
if (userId !== user.id) {
|
|
||||||
await this.getAccessibleTeam(teamId, user);
|
|
||||||
}
|
|
||||||
const isMember = await this.teams.isMember(teamId, userId);
|
const isMember = await this.teams.isMember(teamId, userId);
|
||||||
return { isMember };
|
return { isMember };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Team-scoped access: admins see any team; everyone else only teams they
|
|
||||||
* are a member of. NotFoundException when the team does not exist and
|
|
||||||
* ForbiddenException when the user lacks access (same convention as the
|
|
||||||
* projects controller).
|
|
||||||
*/
|
|
||||||
private async getAccessibleTeam(teamId: string, user: RequestUser) {
|
|
||||||
const team = await this.teams.findById(teamId);
|
|
||||||
if (!team) throw new NotFoundException('Team not found');
|
|
||||||
if (user.role === 'admin') return team;
|
|
||||||
const isMember = await this.teams.isMember(teamId, user.id);
|
|
||||||
if (!isMember) throw new ForbiddenException('Not a member of this team');
|
|
||||||
return team;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -56,21 +56,6 @@ export class TeamsService {
|
|||||||
return this.db.select().from(teams);
|
return this.db.select().from(teams);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* List only the teams the user is a member of.
|
|
||||||
*/
|
|
||||||
async findAllForUser(userId: string) {
|
|
||||||
const memberRows = await this.db
|
|
||||||
.select({ teamId: teamMembers.teamId })
|
|
||||||
.from(teamMembers)
|
|
||||||
.where(eq(teamMembers.userId, userId));
|
|
||||||
|
|
||||||
const teamIds = memberRows.map((r) => r.teamId);
|
|
||||||
if (teamIds.length === 0) return [];
|
|
||||||
|
|
||||||
return this.db.select().from(teams).where(inArray(teams.id, teamIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a team by ID.
|
* Find a team by ID.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1014
-242
File diff suppressed because it is too large
Load Diff
@@ -1,77 +0,0 @@
|
|||||||
---
|
|
||||||
kind: spec
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Mosaic Stack Roadmap
|
|
||||||
|
|
||||||
Companion to [docs/PRD.md](./PRD.md). Governed by the D11 rule: **every planned
|
|
||||||
phase appears here from day one, even as a placeholder** — nothing exists only
|
|
||||||
in heads. A phase marked _placeholder_ is a commitment to design it, not a
|
|
||||||
design; scoping one requires its own PRD section or requirements doc plus
|
|
||||||
review.
|
|
||||||
|
|
||||||
Phases are product phases. The in-flight platform workstreams (KBN-100/101
|
|
||||||
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, and the other
|
|
||||||
Part II contracts in the PRD) run as parallel tracks under their own issues
|
|
||||||
and are prerequisites where noted.
|
|
||||||
|
|
||||||
| Phase | Scope | Status |
|
|
||||||
| ----- | ------------------------------------------------------------------------------ | ----------------------------------------- |
|
|
||||||
| P0 | Current state on `next`: read-only dashboard, chat, auth/SSO login, admin tabs | shipped, evolving |
|
|
||||||
| P1 | **v1 slice** (PRD Part I §9) | next up |
|
|
||||||
| P2 | Connectors + comms + wizard expansion | placeholder |
|
|
||||||
| P3 | Full onboarding profile + M365 | placeholder |
|
|
||||||
| P4 | Enterprise mode + one-way conversion | placeholder |
|
|
||||||
| P5 | Federation | placeholder (deliberately undesigned, D3) |
|
|
||||||
|
|
||||||
## P0 — current state
|
|
||||||
|
|
||||||
What exists on `next` today: web dashboard (login/register/SSO, chat,
|
|
||||||
read-only projects/tasks, settings, admin user/system-health tabs), the
|
|
||||||
Gateway, the CLI-first framework tooling, and the fleet control plane. The
|
|
||||||
webUI audit (USC estate, webui-audit lane) measures the gap between this and
|
|
||||||
P1.
|
|
||||||
|
|
||||||
## P1 — v1 slice (D11)
|
|
||||||
|
|
||||||
1. Standalone onboarding wizard: system/company name, component choices,
|
|
||||||
initial user, initial estate + project, seeded examples, re-runnable.
|
|
||||||
2. Hierarchy core: company → estate → project → workspace → kanban, read-only
|
|
||||||
task bubble-up (kanban SOT Amendment A1 is the schema contract).
|
|
||||||
3. Basic RBAC on the hierarchy.
|
|
||||||
4. Minimal agent enrollment: one harness, API key, name/persona.
|
|
||||||
|
|
||||||
Prerequisites: KBN-100/101 schema foundation; the D8 tool inventory and
|
|
||||||
webUI→tool mapping (any missing tool is built first, D12).
|
|
||||||
|
|
||||||
## P2 — connectors + comms + wizard expansion (placeholder)
|
|
||||||
|
|
||||||
Email and drive connectors (Gmail/IMAP, Google Drive/OneDrive/Dropbox) with
|
|
||||||
granular agentic-access consent; comms integrations (Matrix/Discord/Slack)
|
|
||||||
including agent auto-enroll. Wizard gains the corresponding tabs (D4), plus
|
|
||||||
the D4 capabilities deferred out of P1's minimal slice: expanded agent
|
|
||||||
enrollment (OAuth login, multi-account, model choice with recommendation,
|
|
||||||
account assignment, comms auto-enroll) and the Standalone SSO/OIDC
|
|
||||||
configuration tab.
|
|
||||||
|
|
||||||
## P3 — full onboarding profile + M365 (placeholder)
|
|
||||||
|
|
||||||
Complete user onboarding profile (communication-style capture, optional
|
|
||||||
voice-matching interview) under the D14 custody rule; M365 connectors,
|
|
||||||
available to both deployment modes as ordinary connectors (same consent model
|
|
||||||
as the P2 connector class). The Enterprise install flow's M365 prominence
|
|
||||||
(D4) arrives with the Enterprise phase, P4.
|
|
||||||
|
|
||||||
## P4 — Enterprise mode + conversion (placeholder)
|
|
||||||
|
|
||||||
Enterprise install flow (org chart, RBAC focus, immediate OIDC, SSO
|
|
||||||
prominent); per-user brains with architectural isolation (D14); Vault
|
|
||||||
required; the one-way Standalone → Enterprise conversion (D3).
|
|
||||||
|
|
||||||
## P5 — federation (placeholder)
|
|
||||||
|
|
||||||
Connecting deployments: system-level config, assigned users, rights and
|
|
||||||
data-access control, trusts with boundaries, strict data access, exfiltration
|
|
||||||
monitoring. Explicitly not designed yet (D3); nothing in earlier phases may
|
|
||||||
foreclose it. Requires its own PRD + threat model before any scoping.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,279 +0,0 @@
|
|||||||
# Deployment Mode and Conversion Contract (D3)
|
|
||||||
|
|
||||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 6 of 9).
|
|
||||||
Authority: PRD D3 (Part I §3) — two modes chosen at install time,
|
|
||||||
Standalone and Enterprise, with the mode table (brains, user-data
|
|
||||||
isolation, secrets, conversion); Standalone → Enterprise conversion is
|
|
||||||
**one-way** and Enterprise is a **terminal state**. PRD D14 (Part I §7)
|
|
||||||
— the per-user brain split is optional in Standalone and keeping it is
|
|
||||||
the recommended default because it preserves forward-compatibility with
|
|
||||||
the one-way conversion. PRD D11 (Part I §9) — v1 ships the Standalone
|
|
||||||
flow only; Enterprise conversion is explicitly deferred. PRD D3
|
|
||||||
federation clause — federation is intentionally not fully designed,
|
|
||||||
deferred, and nothing in v1 may foreclose it.
|
|
||||||
|
|
||||||
Revision 2 (luna review F1–F7): the identity precondition restated in
|
|
||||||
identity-contract terms with a conversion-local acknowledgment record
|
|
||||||
this contract owns (F1); a durable, keyed preparation state with a
|
|
||||||
Standalone-safe representation rule, an in-transaction re-check fence,
|
|
||||||
and an exact flip boundary (F2); the §5.4 unknown-value rule stated
|
|
||||||
directly without the contradictory non-exhaustiveness clause (F3); the
|
|
||||||
D14 boundary bound here with a stable column-allowlist witness instead
|
|
||||||
of delegated to an unratified layout (F4); the conversion witness
|
|
||||||
matrix extended to every §4.2/§4.4 condition (F5); the mode-record
|
|
||||||
writer coverage imported concretely from contract 1 §6.3 with a named
|
|
||||||
schema, closed writer set, crafted-write probe, and mode-resolution
|
|
||||||
assertion (F6); the mode read command flagged as a §12.1 drafting
|
|
||||||
addition rather than a D8 mandate (F7). Ownership language aligned
|
|
||||||
with contract 3 revision 2: mode is recorded at bootstrap and read by
|
|
||||||
the wizard as input.
|
|
||||||
|
|
||||||
This contract binds the mode as a canonical platform property (§2), the
|
|
||||||
per-mode obligations and which contract owns each (§3), the conversion
|
|
||||||
transition (§4), the v1 non-foreclosure obligations (§5), and their
|
|
||||||
witnesses (§6). Domain semantics stay with their owning contracts:
|
|
||||||
wizard branching (contract 3 §2), identity/SSO
|
|
||||||
(`identity-lifecycle.md`), custody and per-user brain mechanics
|
|
||||||
(contract 7, `custody-schema.md`), tool mapping
|
|
||||||
(`tool-gateway-mapping.md`).
|
|
||||||
|
|
||||||
## 1. Definitions
|
|
||||||
|
|
||||||
1. **Mode**: the platform-wide deployment mode, exactly one of
|
|
||||||
`standalone` or `enterprise`. The vocabulary is closed in v1;
|
|
||||||
extension (e.g. a federation mode) is by amendment to this contract,
|
|
||||||
never ad hoc.
|
|
||||||
2. **Conversion**: the one-way transition `standalone → enterprise`.
|
|
||||||
No other mode transition exists.
|
|
||||||
3. **Conversion preconditions**: the verifiable conditions of §4.2 that
|
|
||||||
must all hold before the mode record may change.
|
|
||||||
4. **Preparation unit**: one re-runnable piece of pre-conversion work —
|
|
||||||
the migration of one secret to the Vault backend, or the partition
|
|
||||||
of one user's brain content (§4.3).
|
|
||||||
|
|
||||||
## 2. Mode is a canonical recorded property
|
|
||||||
|
|
||||||
1. Mode is recorded canonically in the platform database at bootstrap
|
|
||||||
as the operator's install-time choice (D3: modes are "chosen at
|
|
||||||
install time"). The record is a single-row keyed record
|
|
||||||
(`platform_mode`: mode value, recorded-at timestamp, bootstrap epoch
|
|
||||||
reference); this contract owns it, the bootstrap writer performs the
|
|
||||||
one v1 write (§6.2), and the wizard reads it as input (contract 3
|
|
||||||
§2.3). Mode is never derived from feature state (presence of Vault,
|
|
||||||
count of brains, count of users), and no component may infer a
|
|
||||||
different mode than the record states.
|
|
||||||
2. The record is readable by any authenticated user through a Gateway
|
|
||||||
command with CLI exposure. This read command is a **drafting
|
|
||||||
addition** ratified with this contract (PRD §12.1), not a D8
|
|
||||||
mandate: D8 binds only that any surface exposing the value goes
|
|
||||||
through official tooling. When a webUI surface consumes the read, a
|
|
||||||
mapping row is added to `tool-gateway-mapping.md` by amendment —
|
|
||||||
the same route §4.4 already binds for the conversion command.
|
|
||||||
Components branch on the read value only.
|
|
||||||
3. The record is immutable except by the §4 conversion transition.
|
|
||||||
Editing it by direct database access, config file, environment
|
|
||||||
variable, or wizard re-run is non-conformant (contract 3 §2.3:
|
|
||||||
changing mode later is conversion, not a wizard re-run).
|
|
||||||
|
|
||||||
## 3. Per-mode obligations (owner map)
|
|
||||||
|
|
||||||
The PRD mode table binds four rows; this contract assigns each an
|
|
||||||
owning contract so no obligation is unowned and none is bound twice:
|
|
||||||
|
|
||||||
| Obligation | Standalone | Enterprise | Owner |
|
|
||||||
| ------------------- | -------------------------------------- | -------------------------------------------------- | --------------------------------------------- |
|
|
||||||
| Brains | one mosaic-brain (system + user files) | system brain for config + one brain per user | contract 7 (custody/brain mechanics) |
|
|
||||||
| User-data isolation | single user | no user-data leakage between users; sharing opt-in | contract 7 (enforced by architecture, D14) |
|
|
||||||
| Secrets | OpenBao/Vault or flat files | OpenBao/Vault REQUIRED | this contract (§4.2 gate; steady-state check) |
|
|
||||||
| Conversion | may convert to Enterprise, one-way | terminal state | this contract (§4) |
|
|
||||||
|
|
||||||
The Standalone brains row states the default layout, not the only
|
|
||||||
valid one: the D14 per-user split is a MAY in Standalone with keeping
|
|
||||||
it the recommended default (PRD §7, contract 7 §6), and Vault-backed
|
|
||||||
secrets are equally valid Standalone configuration. Both prepared
|
|
||||||
states are therefore themselves valid Standalone states — the fact
|
|
||||||
§4.3 relies on.
|
|
||||||
|
|
||||||
In Enterprise steady state, a flat-file secrets backend is
|
|
||||||
non-conformant; the platform refuses to start Enterprise-mode
|
|
||||||
components against a flat-file secrets configuration (fail-closed, not
|
|
||||||
warn-and-run).
|
|
||||||
|
|
||||||
## 4. Conversion transition
|
|
||||||
|
|
||||||
1. **Direction and terminality.** The only transition is
|
|
||||||
`standalone → enterprise`. `enterprise → standalone` does not exist:
|
|
||||||
there is no command, no admin override, and no support path. An
|
|
||||||
attempt is refused with the precondition/state error class of the
|
|
||||||
command envelope (`tool-gateway-mapping.md` §4.2).
|
|
||||||
2. **Preconditions (all verified before the record changes):**
|
|
||||||
- Secrets: OpenBao/Vault is configured and reachable, and every
|
|
||||||
required secret is served from the Vault backend — none from a
|
|
||||||
flat-file backend. Secret migration completes before conversion;
|
|
||||||
this contract does not define the migration tooling, only the
|
|
||||||
gate.
|
|
||||||
- Brains: the per-user brain split required by the Enterprise row of
|
|
||||||
§3 is established for **every** existing user (or the deployment
|
|
||||||
already kept the split, the D14 recommended default). Brain
|
|
||||||
partitioning mechanics are contract 7; this contract binds only
|
|
||||||
that the split is complete before the mode flips.
|
|
||||||
- Identity: at least one platform administrator account exists that
|
|
||||||
is active in identity-contract terms — authenticated capability,
|
|
||||||
not banned, not deactivated (identity §2, §5). And the conversion
|
|
||||||
request carries a **configuration acknowledgment**: the current
|
|
||||||
canonical values of registration mode and per-provider JIT
|
|
||||||
enablement (identity §2.2, §4.1), echoed back in the request. A
|
|
||||||
mismatch between the echoed values and the canonical values at
|
|
||||||
verification refuses the conversion. This acknowledgment record
|
|
||||||
is conversion-local, owned by this contract, and stored with the
|
|
||||||
§4.4 audit event as the precondition evidence; it adds no
|
|
||||||
identity-contract obligation and no mode-specific identity
|
|
||||||
default — identity's own defaults remain valid states.
|
|
||||||
3. **Preparation state and the flip boundary.** Preparatory work is
|
|
||||||
tracked durably: each preparation unit (§1.4) records its
|
|
||||||
completion in a preparation table keyed by (bootstrap epoch, unit
|
|
||||||
identity — the secret's path, the user's id), written in the same
|
|
||||||
transaction as the unit's own effect where the unit's backend
|
|
||||||
allows it, and reconciled from the backend's actual state where it
|
|
||||||
does not (a secret already served by Vault, a brain already split,
|
|
||||||
is complete regardless of the table). Units are at-most-once per
|
|
||||||
key and re-runnable across attempts. **Standalone-safe
|
|
||||||
representation:** every preparation unit moves the deployment into
|
|
||||||
a state that is itself valid Standalone configuration (§3 note), so
|
|
||||||
an interrupted preparation leaves a fully operational Standalone
|
|
||||||
deployment reading its state through the ordinary contracts — no
|
|
||||||
rollback, fencing, or special Standalone read path is needed, and
|
|
||||||
no component behavior may key on "preparation in progress".
|
|
||||||
**The flip:** one transaction that (a) locks the mode record, (b)
|
|
||||||
re-verifies every §4.2 precondition after acquiring the lock, and
|
|
||||||
(c) writes the mode record and the §4.4 audit event. Any re-check
|
|
||||||
failure aborts with no write. External state that changes after the
|
|
||||||
re-check but before commit is bounded by the transaction window;
|
|
||||||
an external backend (Vault) failing after conversion is an
|
|
||||||
Enterprise runtime fault handled by §3's fail-closed steady-state
|
|
||||||
rule, not a conversion defect. An interrupted or failed conversion
|
|
||||||
leaves the record `standalone` and the platform fully operational;
|
|
||||||
there is no intermediate mode and no half-converted state
|
|
||||||
observable through the record.
|
|
||||||
4. **Authority and audit.** Conversion is a platform-administrator
|
|
||||||
command carrying an explicit irreversibility acknowledgment in its
|
|
||||||
request (distinct from the §4.2 configuration acknowledgment). It
|
|
||||||
is an official Gateway/CLI command (D8): when built, it is added to
|
|
||||||
the tool↔Gateway mapping by amendment (`tool-gateway-mapping.md`
|
|
||||||
§3.3). The transition emits an audit event (actor, prior mode, new
|
|
||||||
mode, precondition evidence reference including the configuration
|
|
||||||
acknowledgment) in the same transaction as the record change; the
|
|
||||||
event survives indefinitely. A refused attempt emits a refusal
|
|
||||||
event naming the failed precondition class and actor, with no
|
|
||||||
mode-change event.
|
|
||||||
|
|
||||||
## 5. v1 obligations (non-foreclosure)
|
|
||||||
|
|
||||||
v1 ships Standalone only (D11); the conversion command is deferred
|
|
||||||
work. v1 still MUST:
|
|
||||||
|
|
||||||
1. Record the mode per §2 at bootstrap, with `enterprise` a reserved,
|
|
||||||
refused value for bootstrap — v1 bootstrap accepts `standalone`
|
|
||||||
only. The wizard reads the record (contract 3 §2.3); nothing in v1
|
|
||||||
writes it after bootstrap.
|
|
||||||
2. Keep the §2.3 immutability rule: no v1 surface mutates the mode
|
|
||||||
record.
|
|
||||||
3. Not foreclose conversion: the v1 platform database holds no
|
|
||||||
sensitive user content — sensitive categories live in the owning
|
|
||||||
user's brain, and postgres holds structure, consent records, and
|
|
||||||
pointers only (the D14 boundary, PRD §7). Custody mechanics are
|
|
||||||
contract 7's; this contract binds the boundary itself here so v1
|
|
||||||
cannot ship a layout that makes the §4.2 brain precondition
|
|
||||||
unsatisfiable, and §6.3 gives it a stable witness that does not
|
|
||||||
depend on contract 7's internals. Conversion implementation
|
|
||||||
additionally requires contract 7 ratified.
|
|
||||||
4. Not foreclose federation: v1 components accept exactly the two §1.1
|
|
||||||
values wherever a mode value is parsed and refuse any other value
|
|
||||||
**before side effects** — a refused configuration, not undefined
|
|
||||||
behavior and not a crash mid-operation. Forward compatibility lives
|
|
||||||
in storage and architecture, not in parser speculation: the mode
|
|
||||||
record's storage is not structurally locked to two values (no
|
|
||||||
database-level two-value enum), and any future value (e.g. a
|
|
||||||
federation mode) is defined by a versioned amendment to this
|
|
||||||
contract before any component accepts it. The PRD defers
|
|
||||||
federation's shape entirely; this contract does not presume it
|
|
||||||
arrives as a third mode value.
|
|
||||||
|
|
||||||
## 6. Verification requirements
|
|
||||||
|
|
||||||
Binding on the implementing PRs:
|
|
||||||
|
|
||||||
1. **Mode-record witness (v1):** after bootstrap the mode is readable
|
|
||||||
via the Gateway command and CLI and equals the bootstrap-recorded
|
|
||||||
choice; bootstrap with mode `enterprise` is refused; bootstrap with
|
|
||||||
any unknown mode value is refused before side effects (§5.4).
|
|
||||||
2. **Writer-coverage witness (v1):** the mode record's writer set is
|
|
||||||
closed by the same three-prong static assertion contract 1 §6.3(b)
|
|
||||||
defines — symbol, class-table literal, and raw-execution prongs
|
|
||||||
with its allowlist composition rules — scoped to the
|
|
||||||
`platform_mode` table, with a writer allowlist containing exactly
|
|
||||||
the bootstrap writer in v1 (and exactly plus the conversion command
|
|
||||||
at the conversion milestone). Companions: a crafted direct write
|
|
||||||
attempted in a test fails and leaves the record unchanged; a
|
|
||||||
mode-resolution assertion that no shipped component derives mode
|
|
||||||
from feature state (mode reads occur only through the §2.2 read
|
|
||||||
surface — static assertion over Gateway, CLI, bootstrap, and
|
|
||||||
repository sources).
|
|
||||||
3. **D14-boundary witness (v1):** a column-allowlist assertion in the
|
|
||||||
style of contract 1 §6.2 that the platform database schema contains
|
|
||||||
no sensitive-content column — the §5.3 boundary — stable regardless
|
|
||||||
of contract 7's internals (contract 7 §7 carries the full custody
|
|
||||||
witnesses).
|
|
||||||
4. **No-downgrade witness (conversion milestone):** with mode
|
|
||||||
`enterprise`, a conversion request to `standalone` (and any crafted
|
|
||||||
mode-write) is refused with the precondition/state error class and
|
|
||||||
no record change.
|
|
||||||
5. **Precondition witnesses (conversion milestone),** each refused
|
|
||||||
with no record change and no partial mode effect, parameterized
|
|
||||||
over both OpenBao and Vault where secrets are involved:
|
|
||||||
(a) secrets backend unreachable; (b) one required secret still
|
|
||||||
flat-file backed (migration incomplete); (c) one unpartitioned user
|
|
||||||
brain in a **multi-user** deployment where every other user is
|
|
||||||
partitioned; (d) no active platform administrator (the only admin
|
|
||||||
banned or deactivated); (e) configuration acknowledgment missing or
|
|
||||||
mismatching the canonical registration/JIT values; (f) actor not a
|
|
||||||
platform administrator (authorization refusal); (g) irreversibility
|
|
||||||
acknowledgment absent. And the steady-state rule: an
|
|
||||||
Enterprise-mode component started against a flat-file secrets
|
|
||||||
configuration refuses to start (§3).
|
|
||||||
6. **Interruption and fence witnesses (conversion milestone):** fault
|
|
||||||
injection aborting conversion after each preparation unit and
|
|
||||||
between preparation and flip leaves the record `standalone` and the
|
|
||||||
platform operational in Standalone semantics (§4.3
|
|
||||||
Standalone-safety), and a re-attempt completes without duplicating
|
|
||||||
prepared state (at-most-once keys); a precondition invalidated
|
|
||||||
after preparation but before the flip (a secret reverted to
|
|
||||||
flat-file) is caught by the in-transaction re-check and refused.
|
|
||||||
7. **Audit witnesses (conversion milestone):** a completed conversion
|
|
||||||
has exactly one mode-change audit event, same-transaction with the
|
|
||||||
record change (transaction linkage asserted), carrying actor, prior
|
|
||||||
mode, new mode, and the precondition evidence reference including
|
|
||||||
the configuration acknowledgment; a failed attempt has a refusal
|
|
||||||
event naming the failed precondition class and no mode-change
|
|
||||||
event; the mode-change event remains queryable after subsequent
|
|
||||||
unrelated audit activity (retention probe).
|
|
||||||
8. **Mapping witness (conversion milestone):** the conversion command
|
|
||||||
and the mode read command each have their
|
|
||||||
`tool-gateway-mapping.md` row (added by amendment per §2.2/§4.4)
|
|
||||||
before the commands ship.
|
|
||||||
|
|
||||||
## Ruling request
|
|
||||||
|
|
||||||
Ratify sections 1–6 as written, with one decision embedded:
|
|
||||||
|
|
||||||
- Decision (§5): v1 implements the **mode record and its immutability
|
|
||||||
only** — bootstrap records `standalone`, the `enterprise` value is
|
|
||||||
reserved and refused, and the conversion command itself is deferred
|
|
||||||
to the Enterprise milestone, consistent with D11's deferred list.
|
|
||||||
v1 carries three obligations beyond the record: the closed writer
|
|
||||||
assertion, the D14 column boundary, and the unknown-value refusal
|
|
||||||
(§6.1–§6.3) — these are the non-foreclosure floor, not hidden
|
|
||||||
conversion work. Alternative if rejected: build the conversion
|
|
||||||
command inside v1 — rejected because D11 scopes v1 to the Standalone
|
|
||||||
slice and conversion depends on contract 7 custody mechanics that
|
|
||||||
are themselves not in the v1 slice.
|
|
||||||
@@ -372,87 +372,3 @@ The P0–P3 canon does not authorize:
|
|||||||
## 7. Global release evidence
|
## 7. Global release evidence
|
||||||
|
|
||||||
P0–P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence.
|
P0–P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence.
|
||||||
|
|
||||||
## 8. Amendment A1 — hierarchy parentage and RBAC chain above workspaces
|
|
||||||
|
|
||||||
**Status:** amendment to the ratified canon, added by reviewed PR under
|
|
||||||
decision D13 (operator ruling, 2026-08-25; decision owner Jason). It adds
|
|
||||||
parent structure ABOVE workspaces. Sections 1–7, every invariant in §3, and
|
|
||||||
every REQ above remain binding verbatim, with exactly one express modification:
|
|
||||||
the narrow portfolio-analytics carve-out stated in §8.2.4. Nothing else below
|
|
||||||
this line is weakened.
|
|
||||||
|
|
||||||
### 8.1 What is added
|
|
||||||
|
|
||||||
1. A platform hierarchy exists above workspaces:
|
|
||||||
**company/organization → estate → platform-project → workspace**. Each
|
|
||||||
workspace belongs to exactly one platform-project, each platform-project to
|
|
||||||
exactly one estate, each estate to exactly one company.
|
|
||||||
2. **Record class.** Hierarchy records (company, estate, platform-project,
|
|
||||||
their parentage edges, and hierarchy-level access grants) are a new,
|
|
||||||
explicitly named record class: **tenancy/authorization structure records**.
|
|
||||||
They are not business or orchestration records, so §3 invariant 10 and
|
|
||||||
REQ-TEN-001 do not apply to them and are not weakened by them — those two
|
|
||||||
requirements bind business/orchestration rows exactly as before.
|
|
||||||
Constraints on the new class:
|
|
||||||
- Hierarchy tables MUST NOT carry task, plan, or any other
|
|
||||||
business/orchestration payload — parentage, naming, and grant data only.
|
|
||||||
- A hierarchy record can never be the subject of work: it cannot be
|
|
||||||
claimed, ordered, gated, or referenced as a dependency by any
|
|
||||||
business/orchestration row.
|
|
||||||
- Hierarchy mutations flow through the same sole-writable-SOT, fail-closed,
|
|
||||||
audited mutation path as everything else (§8.2.3).
|
|
||||||
3. The hierarchy serves exactly two runtime functions, plus audited
|
|
||||||
maintenance of its own structure:
|
|
||||||
- **RBAC evaluation:** access grants are declared per company, estate, or
|
|
||||||
platform-project and evaluate down the chain to workspace-scoped
|
|
||||||
authorization. Tenant context continues to be derived from authenticated
|
|
||||||
authority (REQ-TEN-001); the chain adds where grants can be declared,
|
|
||||||
not a bypass of workspace authorization.
|
|
||||||
- **Read-only roll-ups:** task and status visualization bubbles up the
|
|
||||||
hierarchy as aggregation over workspaces the reader is authorized on.
|
|
||||||
- **Chain maintenance (not a third runtime function):** re-parenting an
|
|
||||||
asset — moving a workspace to another platform-project, a
|
|
||||||
platform-project to another estate, and so on ("assets are transferable
|
|
||||||
subject to the structure", PRD Part I §4) — is an audited edit of the
|
|
||||||
hierarchy records themselves under §8.3. It never modifies
|
|
||||||
business/orchestration rows and never crosses a workspace boundary for
|
|
||||||
them; the workspace's contents move with the workspace untouched.
|
|
||||||
4. Naming: this amendment says **platform-project** for the hierarchy level
|
|
||||||
above workspaces, because §5 REQ-PLAN-001 already defines `projects` as
|
|
||||||
planning entities INSIDE a workspace. The two are different objects. Final
|
|
||||||
terminology (rename of one or the other) is an implementation-PR decision
|
|
||||||
under this amendment's review; the schema MUST NOT merge them.
|
|
||||||
|
|
||||||
### 8.2 What is explicitly unchanged
|
|
||||||
|
|
||||||
1. `workspace_id` remains the hard mechanical isolation unit (§2 D2,
|
|
||||||
REQ-TEN-001). Hierarchy tables carry parentage; they do not create
|
|
||||||
cross-workspace relationships between business/orchestration rows, which
|
|
||||||
remain rejected (§3 invariant 10).
|
|
||||||
2. Roll-up is **never a write**: no aggregation path may mutate, claim, order,
|
|
||||||
or gate work in any workspace. Bubble-up views are generated projections in
|
|
||||||
the sense of §3 invariant 5 — non-authoritative and never import sources.
|
|
||||||
3. Fail-closed mutation health (§3 invariants 3–4), sole writable PostgreSQL
|
|
||||||
SOT, fencing, audit, and the Coordinator/Certifier authority rules are
|
|
||||||
untouched.
|
|
||||||
4. No §6 non-goal is authorized, with one express, narrow carve-out that this
|
|
||||||
amendment makes to the "portfolio analytics" non-goal: the read-only
|
|
||||||
roll-up of §8.1 — per-workspace task counts and statuses aggregated up the
|
|
||||||
parent chain, over workspaces the reader is authorized on — is in scope.
|
|
||||||
Everything beyond that boundary (metrics, trends, forecasting, scoring,
|
|
||||||
dashboards computed across workspaces, any derived analytic that is not a
|
|
||||||
direct count/status aggregation) remains a non-goal. This is an explicit
|
|
||||||
narrowing by amendment, not a claim that §6 is unchanged; every other §6
|
|
||||||
non-goal is untouched.
|
|
||||||
|
|
||||||
### 8.3 Acceptance (binding on the implementing PRs)
|
|
||||||
|
|
||||||
- Schema tests prove each workspace resolves to exactly one
|
|
||||||
platform-project/estate/company chain and that chain edits are audited.
|
|
||||||
- Authorization tests prove a grant at each hierarchy level yields exactly the
|
|
||||||
workspace permissions the chain implies, and that revocation up the chain
|
|
||||||
propagates.
|
|
||||||
- Negative tests prove roll-up endpoints cannot mutate state and that a
|
|
||||||
reader sees aggregates only over workspaces they are authorized on
|
|
||||||
(no cross-tenant existence oracles).
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
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
@@ -120,13 +120,6 @@
|
|||||||
"when": 1784050648841,
|
"when": 1784050648841,
|
||||||
"tag": "0016_salty_morlocks",
|
"tag": "0016_salty_morlocks",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 17,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1787609223282,
|
|
||||||
"tag": "0017_accounts_issuer",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -51,75 +51,6 @@ describe('runPgliteMigrations', () => {
|
|||||||
await expect(runPgliteMigrations(handle)).resolves.toBeUndefined();
|
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 () => {
|
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,
|
// Pre-create a `users` table that conflicts with migration 0000's CREATE TABLE,
|
||||||
// forcing it to fail without IF NOT EXISTS.
|
// forcing it to fail without IF NOT EXISTS.
|
||||||
|
|||||||
@@ -63,12 +63,6 @@ export const accounts = pgTable(
|
|||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
accountId: text('account_id').notNull(),
|
accountId: text('account_id').notNull(),
|
||||||
providerId: text('provider_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')
|
userId: text('user_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
|||||||
@@ -26,11 +26,6 @@ tools/git/ci-queue-wait.sh --purpose push|merge # REQUIRED before any push/mer
|
|||||||
tools/git/repo-decl.sh # shared .mosaic/repo.json consumption lib (sourced)
|
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_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:
|
`gitea / Error: GetUserByName ... not found`. Pick the login from the repo's `origin` host first:
|
||||||
|
|
||||||
|
|||||||
@@ -49,10 +49,6 @@ supply it explicitly on any host where the provider CLI's default account is an
|
|||||||
| `milestone-list.sh` | List milestones |
|
| `milestone-list.sh` | List milestones |
|
||||||
| `milestone-close.sh` | Close a milestone |
|
| `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 | |
|
| Gates and guards | |
|
||||||
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||||
| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) |
|
| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) |
|
||||||
|
|||||||
@@ -1,343 +0,0 @@
|
|||||||
#!/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)."
|
|
||||||
@@ -1,616 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"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-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"
|
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -172,10 +172,9 @@ export function registerGatewayCommand(program: Command): void {
|
|||||||
.command('recover-token')
|
.command('recover-token')
|
||||||
.description('Recover an admin token — prompts for login if no valid session exists')
|
.description('Recover an admin token — prompts for login if no valid session exists')
|
||||||
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
|
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
|
||||||
.option('-e, --email <email>', 'Headless: account email (password read from stdin line 2)')
|
.action(async (cmdOpts: { gateway?: string }) => {
|
||||||
.action(async (cmdOpts: { gateway?: string; email?: string }) => {
|
|
||||||
const { runRecoverToken } = await import('./gateway/token-ops.js');
|
const { runRecoverToken } = await import('./gateway/token-ops.js');
|
||||||
await runRecoverToken(cmdOpts.gateway, cmdOpts.email);
|
await runRecoverToken(cmdOpts.gateway);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── logs ───────────────────────────────────────────────────────────────
|
// ─── logs ───────────────────────────────────────────────────────────────
|
||||||
@@ -203,14 +202,9 @@ export function registerGatewayCommand(program: Command): void {
|
|||||||
|
|
||||||
gw.command('uninstall')
|
gw.command('uninstall')
|
||||||
.description('Uninstall the gateway daemon and optionally remove data')
|
.description('Uninstall the gateway daemon and optionally remove data')
|
||||||
.option(
|
.action(async () => {
|
||||||
'-y, --yes',
|
|
||||||
'Headless: skip the confirmation prompt (required when stdin is not a TTY)',
|
|
||||||
)
|
|
||||||
.option('--remove-data', 'Also remove all gateway data (never implied by --yes)')
|
|
||||||
.action(async (cmdOpts: { yes?: boolean; removeData?: boolean }) => {
|
|
||||||
const { runUninstall } = await import('./gateway/uninstall.js');
|
const { runUninstall } = await import('./gateway/uninstall.js');
|
||||||
await runUninstall(cmdOpts);
|
await runUninstall();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── doctor ─────────────────────────────────────────────────────────────────
|
// ─── doctor ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -95,17 +95,11 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
|
|||||||
// fatal (#1392): an install that reports success over an empty/partial
|
// fatal (#1392): an install that reports success over an empty/partial
|
||||||
// database is the exact T63 failure this command must never reproduce.
|
// database is the exact T63 failure this command must never reproduce.
|
||||||
let verifyResult: VerifyResult | undefined;
|
let verifyResult: VerifyResult | undefined;
|
||||||
let verificationThrew = false;
|
|
||||||
try {
|
try {
|
||||||
const { runPostInstallVerification } = await import('./verify.js');
|
const { runPostInstallVerification } = await import('./verify.js');
|
||||||
verifyResult = await runPostInstallVerification(configResult.host, configResult.port);
|
verifyResult = await runPostInstallVerification(configResult.host, configResult.port);
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Health/token/bootstrap courtesy failures are non-fatal, but a THROWN
|
// Non-fatal — verification is a courtesy
|
||||||
// schema verification must not let install report success either (N2,
|
|
||||||
// rev-code-02 review 285): mark it and treat as fatal below.
|
|
||||||
verificationThrew = true;
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
prompter.warn(`Post-install verification errored: ${msg}`);
|
|
||||||
}
|
}
|
||||||
if (verifyResult && verifyResult.schemaMigrated === false) {
|
if (verifyResult && verifyResult.schemaMigrated === false) {
|
||||||
prompter.warn(
|
prompter.warn(
|
||||||
@@ -113,12 +107,6 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
|
|||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (verificationThrew) {
|
|
||||||
prompter.warn(
|
|
||||||
'Gateway install ABORTED: post-install verification errored (see above); refusing to report success on an unverified database.',
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Stages normally return structured results for expected failures.
|
// Stages normally return structured results for expected failures.
|
||||||
// Anything that reaches here is an unexpected runtime error — render a
|
// Anything that reaches here is an unexpected runtime error — render a
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { Readable } from 'node:stream';
|
|
||||||
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
|
|
||||||
|
|
||||||
describe('readCredentialsFromPipedStdin — #1394 stdin dual path (real streams)', () => {
|
|
||||||
it('reads exactly two lines; email trimmed, password as-is', async () => {
|
|
||||||
const r = await readCredentialsFromPipedStdin(
|
|
||||||
Readable.from([' [email protected] \n', 'pw with spaces \n']),
|
|
||||||
);
|
|
||||||
expect(r.email).toBe('[email protected]');
|
|
||||||
expect(r.password).toBe('pw with spaces ');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('empty stdin → nulls (the headless-no-credentials shape)', async () => {
|
|
||||||
const r = await readCredentialsFromPipedStdin(Readable.from(['']));
|
|
||||||
expect(r).toEqual({ email: null, password: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('single line only → email set, password null', async () => {
|
|
||||||
const r = await readCredentialsFromPipedStdin(Readable.from(['only-email\n']));
|
|
||||||
expect(r.email).toBe('only-email');
|
|
||||||
expect(r.password).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops after two lines even if more follow', async () => {
|
|
||||||
const r = await readCredentialsFromPipedStdin(
|
|
||||||
Readable.from(['[email protected]\n', 'pw\n', 'extra\n', 'more\n']),
|
|
||||||
);
|
|
||||||
expect(r).toEqual({ email: '[email protected]', password: 'pw' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { createInterface } from 'node:readline';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read email + password as two lines from non-TTY stdin (the headless dual
|
|
||||||
* path for callers that cannot pass argv: printf 'email\npassword\n' | …).
|
|
||||||
* Caller gates on !isTTY; the password line is kept as-is (no trim —
|
|
||||||
* whitespace may be intentional).
|
|
||||||
*
|
|
||||||
* Separate module (not login.ts) so tests can exercise the REAL reader
|
|
||||||
* against real streams while token-ops specs mock this seam cleanly.
|
|
||||||
*/
|
|
||||||
export function readCredentialsFromPipedStdin(
|
|
||||||
input: NodeJS.ReadableStream = process.stdin,
|
|
||||||
): Promise<{ email: string | null; password: string | null }> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const lines: string[] = [];
|
|
||||||
const rl = createInterface({ input });
|
|
||||||
rl.on('line', (l) => {
|
|
||||||
lines.push(l);
|
|
||||||
if (lines.length >= 2) rl.close();
|
|
||||||
});
|
|
||||||
rl.on('close', () => {
|
|
||||||
resolve({ email: (lines[0] ?? '').trim() || null, password: lines[1] ?? null });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -16,20 +16,11 @@ vi.mock('./daemon.js', () => ({
|
|||||||
|
|
||||||
vi.mock('./login.js', () => ({
|
vi.mock('./login.js', () => ({
|
||||||
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
|
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
|
||||||
// promptLine/promptSecret are used by ensureSession on the TTY path; return fixed
|
// promptLine/promptSecret are used by ensureSession; return fixed values so tests don't block on stdin
|
||||||
// values so tests never block on stdin.
|
|
||||||
promptLine: vi.fn().mockResolvedValue('[email protected]'),
|
promptLine: vi.fn().mockResolvedValue('[email protected]'),
|
||||||
promptSecret: vi.fn().mockResolvedValue('test-password'),
|
promptSecret: vi.fn().mockResolvedValue('test-password'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// #1394: non-TTY runs resolve credentials from piped stdin instead of prompts.
|
|
||||||
vi.mock('./piped-credentials.js', () => ({
|
|
||||||
readCredentialsFromPipedStdin: vi.fn().mockResolvedValue({
|
|
||||||
email: '[email protected]',
|
|
||||||
password: 'test-password',
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
vi.stubGlobal('fetch', mockFetch);
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
@@ -74,7 +65,7 @@ describe('ensureSession', () => {
|
|||||||
expect(mockSignIn).not.toHaveBeenCalled();
|
expect(mockSignIn).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves piped-stdin credentials and signs in when stored session is invalid', async () => {
|
it('prompts for credentials and signs in when stored session is invalid', async () => {
|
||||||
mockLoadSession.mockReturnValueOnce({ cookie: 'old-cookie', userId: 'u1', email: '[email protected]' });
|
mockLoadSession.mockReturnValueOnce({ cookie: 'old-cookie', userId: 'u1', email: '[email protected]' });
|
||||||
mockValidateSession.mockResolvedValueOnce(false);
|
mockValidateSession.mockResolvedValueOnce(false);
|
||||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||||
@@ -85,7 +76,7 @@ describe('ensureSession', () => {
|
|||||||
expect(mockSaveSession).toHaveBeenCalledWith(baseUrl, newAuth);
|
expect(mockSaveSession).toHaveBeenCalledWith(baseUrl, newAuth);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves piped-stdin credentials when no session exists', async () => {
|
it('prompts for credentials when no session exists', async () => {
|
||||||
mockLoadSession.mockReturnValueOnce(null);
|
mockLoadSession.mockReturnValueOnce(null);
|
||||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||||
mockSignIn.mockResolvedValueOnce(newAuth);
|
mockSignIn.mockResolvedValueOnce(newAuth);
|
||||||
@@ -93,10 +84,6 @@ describe('ensureSession', () => {
|
|||||||
const cookie = await ensureSession(baseUrl);
|
const cookie = await ensureSession(baseUrl);
|
||||||
expect(cookie).toBe(fakeCookie);
|
expect(cookie).toBe(fakeCookie);
|
||||||
expect(mockSignIn).toHaveBeenCalled();
|
expect(mockSignIn).toHaveBeenCalled();
|
||||||
// The non-TTY path resolves credentials from the piped-stdin seam, not prompts.
|
|
||||||
expect(
|
|
||||||
vi.mocked(await import('./piped-credentials.js')).readCredentialsFromPipedStdin,
|
|
||||||
).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exits non-zero when signIn fails', async () => {
|
it('exits non-zero when signIn fails', async () => {
|
||||||
@@ -124,7 +111,7 @@ describe('runRecoverToken', () => {
|
|||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('signs in via piped stdin, mints a token, and persists it when no session exists', async () => {
|
it('prompts for login, mints a token, and persists it when no session exists', async () => {
|
||||||
mockLoadSession.mockReturnValueOnce(null);
|
mockLoadSession.mockReturnValueOnce(null);
|
||||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||||
mockSignIn.mockResolvedValueOnce(newAuth);
|
mockSignIn.mockResolvedValueOnce(newAuth);
|
||||||
|
|||||||
@@ -153,29 +153,4 @@ describe('resolveSchemaCheckConfigPath', () => {
|
|||||||
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
|
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives MOSAIC_CONFIG NO authority (N1, review 285): env never overrides file resolution', async () => {
|
|
||||||
const { resolveSchemaCheckConfigPath } = await import('./schema-check.js');
|
|
||||||
const daemonDir = mkdtempSync(join(tmpdir(), 'schema-check-env-'));
|
|
||||||
tmpDirs.push(daemonDir);
|
|
||||||
const daemonHome = join(daemonDir, '.config', 'mosaic', 'gateway');
|
|
||||||
mkdirSync(daemonHome, { recursive: true });
|
|
||||||
writeFileSync(join(daemonHome, 'mosaic.config.json'), JSON.stringify(LOCAL_CFG));
|
|
||||||
// A stale env var pointing at a DIFFERENT file must be ignored entirely:
|
|
||||||
const decoyDir = mkdtempSync(join(tmpdir(), 'schema-check-decoy-'));
|
|
||||||
tmpDirs.push(decoyDir);
|
|
||||||
const decoyPath = join(decoyDir, 'mosaic.config.json');
|
|
||||||
writeFileSync(decoyPath, JSON.stringify(STANDALONE_CFG));
|
|
||||||
|
|
||||||
const prevHome = process.env['HOME'];
|
|
||||||
vi.stubEnv('HOME', daemonDir);
|
|
||||||
vi.stubEnv('MOSAIC_CONFIG', decoyPath);
|
|
||||||
try {
|
|
||||||
const resolved = resolveSchemaCheckConfigPath();
|
|
||||||
expect(resolved).toBe(join(daemonHome, 'mosaic.config.json'));
|
|
||||||
expect(resolved).not.toBe(decoyPath);
|
|
||||||
} finally {
|
|
||||||
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,11 +53,8 @@ export const SCHEMA_FAIL_REMEDIATION = [
|
|||||||
*/
|
*/
|
||||||
export function resolveSchemaCheckConfigPath(explicit?: string): string | undefined {
|
export function resolveSchemaCheckConfigPath(explicit?: string): string | undefined {
|
||||||
if (explicit) return resolve(explicit);
|
if (explicit) return resolve(explicit);
|
||||||
// NOTE: no env-var candidate, deliberately. apps/gateway/src/env.ts gives env
|
|
||||||
// NO config authority (a stale MOSAIC_CONFIG could verify a database the
|
|
||||||
// daemon never reads — rev-code-02 review 285, note N1). Resolution order
|
|
||||||
// mirrors the daemon's file priorities only.
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
|
process.env['MOSAIC_CONFIG'],
|
||||||
join(homedir(), '.config', 'mosaic', 'gateway', 'mosaic.config.json'), // daemon-written
|
join(homedir(), '.config', 'mosaic', 'gateway', 'mosaic.config.json'), // daemon-written
|
||||||
resolve(process.cwd(), 'mosaic.config.json'),
|
resolve(process.cwd(), 'mosaic.config.json'),
|
||||||
join(homedir(), '.mosaic', 'mosaic.config.json'),
|
join(homedir(), '.mosaic', 'mosaic.config.json'),
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
|
|
||||||
vi.mock('../../auth.js', () => ({
|
|
||||||
loadSession: vi.fn(),
|
|
||||||
validateSession: vi.fn(),
|
|
||||||
signIn: vi.fn(),
|
|
||||||
saveSession: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./login.js', () => ({
|
|
||||||
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
|
|
||||||
promptLine: vi.fn(),
|
|
||||||
promptSecret: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./piped-credentials.js', () => ({
|
|
||||||
readCredentialsFromPipedStdin: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./daemon.js', () => ({
|
|
||||||
readMeta: vi.fn(),
|
|
||||||
writeMeta: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { ensureSession } from './token-ops.js';
|
|
||||||
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
|
|
||||||
import { promptLine, promptSecret } from './login.js';
|
|
||||||
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
|
|
||||||
|
|
||||||
const URL = 'http://localhost:14242';
|
|
||||||
|
|
||||||
function asNonTTY(): void {
|
|
||||||
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ensureSession — #1394 credential precedence (flag > piped stdin > prompt)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.mocked(loadSession).mockReturnValue(null);
|
|
||||||
asNonTTY();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stored valid session wins; no credentials touched', async () => {
|
|
||||||
vi.mocked(loadSession).mockReturnValue({ cookie: 'SESS', email: '[email protected]' } as never);
|
|
||||||
vi.mocked(validateSession).mockResolvedValue(true);
|
|
||||||
await expect(ensureSession(URL)).resolves.toBe('SESS');
|
|
||||||
expect(signIn).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flag email + stdin password: FLAG wins for email, stdin supplies the password', async () => {
|
|
||||||
vi.mocked(signIn).mockResolvedValue({ cookie: 'NEW', email: '[email protected]' } as never);
|
|
||||||
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
|
|
||||||
email: '[email protected]',
|
|
||||||
password: 'stdin-pw',
|
|
||||||
});
|
|
||||||
|
|
||||||
await ensureSession(URL, { email: '[email protected]' });
|
|
||||||
|
|
||||||
expect(signIn).toHaveBeenCalledWith(URL, '[email protected]', 'stdin-pw');
|
|
||||||
expect(promptLine).not.toHaveBeenCalled();
|
|
||||||
expect(promptSecret).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stdin-only path (no flag): both credentials from piped lines', async () => {
|
|
||||||
vi.mocked(signIn).mockResolvedValue({ cookie: 'NEW2', email: '[email protected]' } as never);
|
|
||||||
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
|
|
||||||
email: '[email protected]',
|
|
||||||
password: 'spw',
|
|
||||||
});
|
|
||||||
|
|
||||||
await ensureSession(URL);
|
|
||||||
expect(signIn).toHaveBeenCalledWith(URL, '[email protected]', 'spw');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('no credentials headless → exit(2) with --email guidance; signIn untouched', async () => {
|
|
||||||
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({ email: null, password: null });
|
|
||||||
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
||||||
throw new Error('EXIT');
|
|
||||||
}) as never);
|
|
||||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
await expect(ensureSession(URL)).rejects.toThrow('EXIT');
|
|
||||||
expect(exit).toHaveBeenCalledWith(2);
|
|
||||||
expect(err).toHaveBeenCalledWith(expect.stringContaining('--email'));
|
|
||||||
expect(signIn).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
exit.mockRestore();
|
|
||||||
err.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('successful sign-in persists the session', async () => {
|
|
||||||
vi.mocked(signIn).mockResolvedValue({ cookie: 'C', email: '[email protected]' } as never);
|
|
||||||
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
|
|
||||||
email: '[email protected]',
|
|
||||||
password: 'pw',
|
|
||||||
});
|
|
||||||
|
|
||||||
await ensureSession(URL);
|
|
||||||
expect(saveSession).toHaveBeenCalledWith(URL, expect.anything());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
|
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
|
||||||
import { readMeta, writeMeta } from './daemon.js';
|
import { readMeta, writeMeta } from './daemon.js';
|
||||||
import { getGatewayUrl, promptLine, promptSecret } from './login.js';
|
import { getGatewayUrl, promptLine, promptSecret } from './login.js';
|
||||||
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
|
|
||||||
|
|
||||||
interface MintedToken {
|
interface MintedToken {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -108,24 +107,8 @@ export async function requireSession(gatewayUrl: string): Promise<string> {
|
|||||||
* Ensure a valid session for the gateway, prompting for credentials if needed.
|
* Ensure a valid session for the gateway, prompting for credentials if needed.
|
||||||
* On sign-in failure, prints the error and exits non-zero.
|
* On sign-in failure, prints the error and exits non-zero.
|
||||||
* Returns the session cookie.
|
* Returns the session cookie.
|
||||||
*
|
|
||||||
* Credential precedence when sign-in is needed (#1394):
|
|
||||||
* 1. explicit opts (--email flag; highest)
|
|
||||||
* 2. non-TTY stdin — first line email, second line password (headless dual
|
|
||||||
* path for callers without argv access: printf 'email\npassword\n' | …)
|
|
||||||
* 3. interactive prompt (TTY only)
|
|
||||||
*/
|
*/
|
||||||
export interface SessionCredentialOptions {
|
export async function ensureSession(gatewayUrl: string): Promise<string> {
|
||||||
/** Email from an explicit flag (argv). Highest precedence. */
|
|
||||||
email?: string;
|
|
||||||
/** Password from an explicit source. Rare; passwords normally come via stdin/prompt. */
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureSession(
|
|
||||||
gatewayUrl: string,
|
|
||||||
opts: SessionCredentialOptions = {},
|
|
||||||
): Promise<string> {
|
|
||||||
// Try the stored session first
|
// Try the stored session first
|
||||||
const session = loadSession(gatewayUrl);
|
const session = loadSession(gatewayUrl);
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -136,25 +119,10 @@ export async function ensureSession(
|
|||||||
console.log(`No session found for ${gatewayUrl}. Please sign in.`);
|
console.log(`No session found for ${gatewayUrl}. Please sign in.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let email = opts.email;
|
// Prompt for credentials — password must not be echoed to the terminal
|
||||||
let password = opts.password;
|
const email = await promptLine('Email: ');
|
||||||
if ((!email || !password) && !process.stdin.isTTY) {
|
// Do not trim password — it may contain intentional leading/trailing whitespace
|
||||||
const piped = await readCredentialsFromPipedStdin();
|
const password = await promptSecret('Password: ');
|
||||||
email = email ?? piped.email ?? undefined;
|
|
||||||
password = password ?? piped.password ?? undefined;
|
|
||||||
}
|
|
||||||
if (!email || !password) {
|
|
||||||
if (!process.stdin.isTTY) {
|
|
||||||
console.error(
|
|
||||||
'No valid session and no credentials available headlessly. Provide --email plus ' +
|
|
||||||
"a password line on stdin (printf 'email\\npassword\\n' | …), or run interactively.",
|
|
||||||
);
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
email = await promptLine('Email: ');
|
|
||||||
// Do not trim password — it may contain intentional leading/trailing whitespace
|
|
||||||
password = await promptSecret('Password: ');
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
|
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
@@ -178,12 +146,11 @@ export async function runRotateToken(gatewayUrl?: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `mosaic gateway config recover-token` — signs in if no session exists.
|
* `mosaic gateway config recover-token` — prompts for login if no session exists.
|
||||||
* Passes the --email flag through to ensureSession (#1394 dual path).
|
|
||||||
*/
|
*/
|
||||||
export async function runRecoverToken(gatewayUrl?: string, email?: string): Promise<void> {
|
export async function runRecoverToken(gatewayUrl?: string): Promise<void> {
|
||||||
const url = getGatewayUrl(gatewayUrl);
|
const url = getGatewayUrl(gatewayUrl);
|
||||||
const cookie = await ensureSession(url, { email });
|
const cookie = await ensureSession(url);
|
||||||
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
|
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
|
||||||
const minted = await mintAdminToken(url, cookie, label);
|
const minted = await mintAdminToken(url, cookie, label);
|
||||||
persistToken(url, minted);
|
persistToken(url, minted);
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { mkdirSync } from 'node:fs';
|
|
||||||
|
|
||||||
vi.mock('./daemon.js', () => ({
|
|
||||||
GATEWAY_HOME: '/tmp/u-test-gateway-home',
|
|
||||||
getDaemonPid: vi.fn().mockReturnValue(null),
|
|
||||||
readMeta: vi.fn(),
|
|
||||||
stopDaemon: vi.fn(),
|
|
||||||
uninstallGatewayPackage: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { runUninstall } from './uninstall.js';
|
|
||||||
import { readMeta, uninstallGatewayPackage } from './daemon.js';
|
|
||||||
|
|
||||||
describe('gateway uninstall — #1390 headless semantics', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('non-TTY without --yes FAILS LOUD (exit 1, nothing touched)', async () => {
|
|
||||||
vi.mocked(readMeta).mockReturnValue({
|
|
||||||
version: '0.0.7',
|
|
||||||
installedAt: '',
|
|
||||||
entryPoint: '',
|
|
||||||
host: 'localhost',
|
|
||||||
port: 14242,
|
|
||||||
});
|
|
||||||
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
||||||
throw new Error('EXIT');
|
|
||||||
}) as never);
|
|
||||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
await expect(runUninstall()).rejects.toThrow('EXIT');
|
|
||||||
expect(exit).toHaveBeenCalledWith(1);
|
|
||||||
expect(err).toHaveBeenCalledWith(expect.stringContaining('stdin is not a TTY'));
|
|
||||||
expect(uninstallGatewayPackage).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
exit.mockRestore();
|
|
||||||
err.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('--yes proceeds headlessly WITHOUT removing data (never implied)', async () => {
|
|
||||||
const meta = {
|
|
||||||
version: '0.0.7',
|
|
||||||
installedAt: '',
|
|
||||||
entryPoint: '',
|
|
||||||
host: 'localhost',
|
|
||||||
port: 14242,
|
|
||||||
};
|
|
||||||
vi.mocked(readMeta).mockReturnValue(meta);
|
|
||||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
|
|
||||||
await runUninstall({ yes: true });
|
|
||||||
|
|
||||||
expect(uninstallGatewayPackage).toHaveBeenCalledTimes(1);
|
|
||||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('Gateway data kept'));
|
|
||||||
log.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('--yes --remove-data removes data headlessly', async () => {
|
|
||||||
vi.mocked(readMeta).mockReturnValue({
|
|
||||||
version: '0.0.7',
|
|
||||||
installedAt: '',
|
|
||||||
entryPoint: '',
|
|
||||||
host: 'localhost',
|
|
||||||
port: 14242,
|
|
||||||
});
|
|
||||||
mkdirSync('/tmp/u-test-gateway-home', { recursive: true }); // existsSync gate
|
|
||||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
|
|
||||||
await runUninstall({ yes: true, removeData: true });
|
|
||||||
|
|
||||||
expect(uninstallGatewayPackage).toHaveBeenCalledTimes(1);
|
|
||||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('Gateway data removed'));
|
|
||||||
log.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('no meta → clean no-op even with --yes', async () => {
|
|
||||||
vi.mocked(readMeta).mockReturnValue(null);
|
|
||||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
await runUninstall({ yes: true });
|
|
||||||
expect(log).toHaveBeenCalledWith('Gateway is not installed.');
|
|
||||||
expect(uninstallGatewayPackage).not.toHaveBeenCalled();
|
|
||||||
log.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -8,65 +8,30 @@ import {
|
|||||||
uninstallGatewayPackage,
|
uninstallGatewayPackage,
|
||||||
} from './daemon.js';
|
} from './daemon.js';
|
||||||
|
|
||||||
export interface UninstallOptions {
|
export async function runUninstall(): Promise<void> {
|
||||||
/** Skip the confirmation prompt (headless/scripted uninstall). */
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
yes?: boolean;
|
|
||||||
/** Also remove all gateway data at GATEWAY_HOME (never implied by --yes). */
|
|
||||||
removeData?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runUninstall(opts: UninstallOptions = {}): Promise<void> {
|
|
||||||
const nonInteractive = Boolean(opts.yes) || process.env['MOSAIC_ASSUME_YES'] === '1';
|
|
||||||
|
|
||||||
// Non-TTY without explicit consent must FAIL LOUD, not quietly do nothing:
|
|
||||||
// the pre-fix behavior (prompt on a closed stdin → default No → exit 0,
|
|
||||||
// gateway untouched) reported success-by-silence to every scripted caller
|
|
||||||
// (#1390). An explicit refusal beats a silent no-op.
|
|
||||||
if (!nonInteractive && !process.stdin.isTTY) {
|
|
||||||
console.error(
|
|
||||||
'gateway uninstall: stdin is not a TTY and no --yes was given — refusing to ' +
|
|
||||||
'run an interactive uninstall headlessly (nothing was changed). ' +
|
|
||||||
'Use --yes (and --remove-data to also delete gateway data), or run from a terminal.',
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rl = nonInteractive
|
|
||||||
? null
|
|
||||||
: createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
try {
|
try {
|
||||||
await doUninstall(rl as NonNullable<typeof rl>, opts, nonInteractive);
|
await doUninstall(rl);
|
||||||
} finally {
|
} finally {
|
||||||
rl?.close();
|
rl.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function prompt(
|
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||||
rl: NonNullable<ReturnType<typeof createInterface>>,
|
|
||||||
question: string,
|
|
||||||
): Promise<string> {
|
|
||||||
return new Promise((resolve) => rl.question(question, resolve));
|
return new Promise((resolve) => rl.question(question, resolve));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doUninstall(
|
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||||
rl: ReturnType<typeof createInterface>,
|
|
||||||
opts: UninstallOptions,
|
|
||||||
nonInteractive: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
const meta = readMeta();
|
const meta = readMeta();
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
console.log('Gateway is not installed.');
|
console.log('Gateway is not installed.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nonInteractive) {
|
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
|
||||||
console.log(`Uninstalling Mosaic Gateway (--yes${opts.removeData ? ' --remove-data' : ''})...`);
|
if (answer.toLowerCase() !== 'y') {
|
||||||
} else {
|
console.log('Aborted.');
|
||||||
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
|
return;
|
||||||
if (answer.toLowerCase() !== 'y') {
|
|
||||||
console.log('Aborted.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop if running
|
// Stop if running
|
||||||
@@ -80,20 +45,13 @@ async function doUninstall(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove config/data. Interactive: ask. Headless: only with the explicit
|
// Remove config/data
|
||||||
// flag — destructive recursion is never implied by --yes alone (#1390).
|
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
|
||||||
let removeData = Boolean(opts.removeData);
|
if (removeData.toLowerCase() === 'y') {
|
||||||
if (!nonInteractive) {
|
|
||||||
const answer = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
|
|
||||||
removeData = answer.toLowerCase() === 'y';
|
|
||||||
}
|
|
||||||
if (removeData) {
|
|
||||||
if (existsSync(GATEWAY_HOME)) {
|
if (existsSync(GATEWAY_HOME)) {
|
||||||
rmSync(GATEWAY_HOME, { recursive: true, force: true });
|
rmSync(GATEWAY_HOME, { recursive: true, force: true });
|
||||||
console.log('Gateway data removed.');
|
console.log('Gateway data removed.');
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log(`Gateway data kept at ${GATEWAY_HOME}.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uninstall npm package
|
// Uninstall npm package
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export async function runPostInstallVerification(
|
|||||||
const { runMigrations, getMigrationStatus } = await import('@mosaicstack/db');
|
const { runMigrations, getMigrationStatus } = await import('@mosaicstack/db');
|
||||||
const result = await checkDatabaseSchema(
|
const result = await checkDatabaseSchema(
|
||||||
{ runMigrations, getMigrationStatus },
|
{ runMigrations, getMigrationStatus },
|
||||||
undefined, // resolver mirrors daemon file priorities; env has no config authority (N1)
|
process.env['MOSAIC_CONFIG'],
|
||||||
);
|
);
|
||||||
if (result.status === 'ok') {
|
if (result.status === 'ok') {
|
||||||
ok(result.detail);
|
ok(result.detail);
|
||||||
|
|||||||
Reference in New Issue
Block a user