Compare commits

...
Author SHA1 Message Date
fred a251a80f25 ci: publish web+appservice sha images on next (#1407)
ci/woodpecker/pr/ci Pipeline was successful
build-web and build-appservice sat on a main-only when-anchor while
build-gateway used the next-capable one, so next-lane pushes produced
gateway sha images with no web/appservice counterpart — no sha-parity
image set existed for next-lane containerized deploys (measured: web and
appservice each had exactly one sha tag, the month-old last main build).

Fix: single shared image_build_when anchor for all three image steps;
web/appservice adopt the gateway-style branch guard (next = sha-only
destinations, tag refused; main adds :latest; anything else requires a
tag). The divergent second anchor is removed — two near-identical anchors
silently drifting apart is the defect class itself.

scripts/verify-release.test.mjs: 11/11 pass (SDLC-D-034 gate unchanged;
depends_on untouched, publish-next-npm serialization edge intact).
2026-08-26 17:10:46 -05:00
jason.woltje 3bd490c080 Merge pull request 'docs: north-star PRD rewrite (D1-D14), ROADMAP, kanban SOT Amendment A1' (#1425) from docs/prd-north-star-rewrite into next
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: #1425
2026-08-26 16:57:16 +00:00
fred 4b448109dd docs/prd: address independent review findings 1-10 (fidelity, A1 record class + carve-out, roadmap completeness)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-25 23:20:40 -05:00
fred bc1149c15e docs: north-star PRD rewrite (D1-D14), ROADMAP.md, kanban SOT Amendment A1
ci/woodpecker/pr/ci Pipeline was successful
- docs/PRD.md: Part I product north star authored from ratified decisions
  D1-D14; Part II preserves all active workstream contracts verbatim
  (KBN-101, FCM #758, FCOM #766, TESS, #756, MOS-PORT, #1150, #1174, #1194,
  RI #1275, M1). Referenced anchors unchanged.
- docs/archive/PRD-v0.1.md: v0.1.0 beta PRD body archived verbatim with
  supersession header.
- docs/ROADMAP.md: all phases P0-P5 present from day one per D11
  (P2-P5 as explicit placeholders).
- docs/requirements/native-kanban-sot.md: Amendment A1 (D13) - hierarchy
  parentage + RBAC chain above workspaces; sections 1-7 untouched.
2026-08-25 22:23:07 -05:00
orch-01 089953a7cf ci: enable turbo remote cache on trusted publish events (#1424)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: orch-01 <[email protected]>
2026-08-25 18:32:55 +00:00
ops-deploy-01 7b25be22e9 fix(#1394): recover-token headless — dual path (--email flag + piped stdin) with documented precedence (#1423)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: ops-deploy-01 <[email protected]>
2026-08-25 16:14:56 +00:00
ops-deploy-01 4e3d179e61 fix(#1390): gateway uninstall headless — --yes/--remove-data; non-TTY without consent fails loud (#1422)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: ops-deploy-01 <[email protected]>
2026-08-25 16:00:17 +00:00
ops-deploy-01andorch-01 ae58482b72 fix(#1392): review-285 N1+N2 follow-up — env has no config authority in schema-check; verification throws fatal at install (#1421)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: ops-deploy-01 <[email protected]>
2026-08-25 15:08:04 +00:00
code-infra-01 b2d40dada0 fix(#1391): boot-time ValidationPipe metatype self-check — fail loud at startup (#1419)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-infra-01 <[email protected]>
2026-08-25 14:01:24 +00:00
21 changed files with 2093 additions and 1065 deletions
+6 -4
View File
@@ -38,10 +38,12 @@ when:
- event: push
branch: main
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
# This avoids from_secret which is blocked on pull_request events.
# If the env vars aren't set, turbo falls back to local cache only.
# Turbo remote cache (turbo.mosaicstack.dev) is wired in publish.yml via the
# org-level Woodpecker secret `turbo_token` (events: push/tag/cron/manual/
# deployment — never pull_request). This PR pipeline deliberately gets no
# remote-cache credentials: an untrusted PR must not be able to write to (or
# poison) the shared cache. Without TURBO_* env vars turbo falls back to
# local cache only, which is the intended behavior here.
steps:
install:
+41 -14
View File
@@ -32,6 +32,11 @@ variables:
# non-excluded change still builds, so no transitive dep can silently go stale.
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
# the separate `event: tag` entry.)
# #1407: ONE shared anchor for all three image steps. A second main-only
# anchor previously gated build-web/build-appservice, so next-lane pushes
# published gateway sha images with no web/appservice counterpart — no
# sha-parity set existed for next-lane containerized deploys. Every image
# step now builds on next too (sha-only destinations, enforced per step).
- &image_build_when
- event: tag
- event: [push, manual]
@@ -44,16 +49,6 @@ variables:
- '.woodpecker/**'
- event: [push, manual]
branch: next
- &main_image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when:
- branch: [main, next]
@@ -73,6 +68,13 @@ steps:
# being empty) and on any incomplete verification.
verify:
image: *node_image
environment:
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
TURBO_API: https://turbo.mosaicstack.dev
TURBO_TEAM: mosaic
TURBO_TOKEN:
from_secret: turbo_token
commands:
- *enable_pnpm
# (a) Commit identity: the provider's claimed SHA must equal the actual
@@ -108,6 +110,13 @@ steps:
build:
image: *node_image
environment:
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
TURBO_API: https://turbo.mosaicstack.dev
TURBO_TEAM: mosaic
TURBO_TOKEN:
from_secret: turbo_token
commands:
- *enable_pnpm
- pnpm build
@@ -460,7 +469,7 @@ steps:
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
@@ -474,8 +483,17 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next appservice publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
@@ -495,7 +513,7 @@ steps:
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
@@ -509,8 +527,17 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next web publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
+6
View File
@@ -14,10 +14,16 @@ import { mountMcpHandler } from './mcp/mcp.controller.js';
import { McpService } from './mcp/mcp.service.js';
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
import { resolveGatewayConfigPath } from './env.js';
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap');
// Fail loud BEFORE anything else if the global ValidationPipe cannot see
// the guarded DTOs' decorated properties (#1391): a broken metatype turns
// every request body into a 400 at first use; this surfaces it at boot.
assertValidationPipeSeesDtoDecorators();
if (!process.env['BETTER_AUTH_SECRET']) {
throw new Error('BETTER_AUTH_SECRET is required');
}
@@ -0,0 +1,104 @@
/**
* Boot-time ValidationPipe metatype self-check (#1391).
*
* The check exists to fail loud at boot when the global pipe cannot see a
* guarded DTO's decorated properties — the #436 class-erasure signature and
* its dependency-graph cousins. Red/green arms:
*
* GREEN real module state: BootstrapSetupDto's three properties are
* decorated and visible through the globalThis-shared storage.
* RED a control class with NO decorators (the erasure shape): the
* check throws PipeMetatypeCheckError naming every property.
* RED-2 a control where one property is decorated and two are not: the
* error names exactly the missing two — the miss list is precise,
* not a blanket failure.
*/
import { describe, expect, it } from 'vitest';
import { IsString } from 'class-validator';
import {
assertValidationPipeSeesDtoDecorators,
PipeMetatypeCheckError,
} from './validation-pipe-check.js';
describe('assertValidationPipeSeesDtoDecorators (#1391 boot check)', () => {
it('GREEN: passes on real module state (decorated DTO visible to the pipe)', () => {
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
});
it('RED control: a class whose properties lost their decorators throws, naming them', async () => {
// Simulate metatype erasure: an undecorated class standing where a
// decorated DTO should be. Redefine the guard table for the test by
// importing the module and pointing its table at the eroded class —
// the check reads the table at call time, so a fresh module instance
// with a swapped table reproduces the boot failure deterministically.
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
class ErodedDto {
name?: string;
email?: string;
password?: string;
}
const original = PIPE_GUARDED_DTOS[0];
expect(original).toBeDefined();
// Swap in the eroded target (same declared properties, zero decorators).
(
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
).splice(0, PIPE_GUARDED_DTOS.length, {
name: 'ErodedDto',
target: ErodedDto,
properties: ['name', 'email', 'password'],
});
try {
expect(() => assertValidationPipeSeesDtoDecorators()).toThrow(PipeMetatypeCheckError);
try {
assertValidationPipeSeesDtoDecorators();
} catch (err) {
const message = err instanceof Error ? err.message : '';
expect(message).toContain('ErodedDto.name');
expect(message).toContain('ErodedDto.email');
expect(message).toContain('ErodedDto.password');
}
} finally {
// Restore real module state for any later test in this file.
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
}
// And confirm the restore is real.
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
});
it('RED-2 control: a partially decorated class names exactly the missing properties', async () => {
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
class HalfErodedDto {
@IsString()
name?: string;
email?: string;
password?: string;
}
const original = PIPE_GUARDED_DTOS[0];
(
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
).splice(0, PIPE_GUARDED_DTOS.length, {
name: 'HalfErodedDto',
target: HalfErodedDto,
properties: ['name', 'email', 'password'],
});
try {
try {
assertValidationPipeSeesDtoDecorators();
expect.unreachable('partially decorated DTO must fail the boot check');
} catch (err) {
const message = err instanceof Error ? err.message : '';
expect(message).toContain('HalfErodedDto.email');
expect(message).toContain('HalfErodedDto.password');
expect(message).not.toContain('HalfErodedDto.name has no');
}
} finally {
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
}
});
});
+94
View File
@@ -0,0 +1,94 @@
import 'reflect-metadata';
import { getMetadataStorage } from 'class-validator';
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
/**
* Boot-time self-check: the global ValidationPipe must be able to SEE the
* decorated properties of the DTOs it guards (#1391, #436 class).
*
* WHY THIS EXISTS. When Nest resolves a @Body() metatype to Object — via
* `import type` class erasure (#436), or a dependency graph where the
* controller's decorators and the application's route enhancers disagree
* (#1391's hypothesized dual-@nestjs/common on a mixed install) — the
* ValidationPipe's whitelist treats every property as forbidden. The first
* symptom is a 400 on the FIRST bootstrap attempt of a fresh install, the
* worst place to discover wiring damage: the operator cannot tell a broken
* payload from a broken daemon.
*
* This check fails LOUD at boot instead: if the pipe cannot see the DTO's
* decorated properties, the gateway refuses to start with a named cause.
* It catches the whole class — erasure, decorator metadata loss — on every
* host, at the moment the damage exists rather than at first use.
*
* Storage sharing note: class-validator keys its metadata storage on
* globalThis, so duplicate package copies do NOT hide metadata (measured,
* #1391 diagnosis). What hides it is losing the metatype itself, which is
* what this asserts against.
*/
/**
* DTOs the global pipe guards, mapped to the properties the whitelist must
* admit. Target is the CONSTRUCTOR (the object class itself): class-validator
* decorators register metadata keyed on the constructor, and its executor
* looks up `object.constructor` (ValidationExecutor.js:50) — the probe
* through `prototype` returns zero. Extend when adding DTOs to the app.
*/
export const PIPE_GUARDED_DTOS: Array<{
name: string;
target: abstract new (...args: never[]) => unknown;
properties: string[];
}> = [
{
name: 'BootstrapSetupDto',
target: BootstrapSetupDto,
properties: ['name', 'email', 'password'],
},
];
export class PipeMetatypeCheckError extends Error {
constructor(missing: string[]) {
super(
'ValidationPipe metatype check failed: ' +
missing.join('; ') +
'. The global ValidationPipe cannot see decorated DTO properties — ' +
'every request body would be rejected as non-whitelisted. ' +
'Check for import-type erasure or decorator metadata loss in the ' +
'dependency graph (see issues #436, #1391).',
);
this.name = 'PipeMetatypeCheckError';
}
}
/**
* Assert the pipe's whitelist can see every guarded DTO's decorated
* properties. Throws PipeMetatypeCheckError (fail-loud at boot) listing
* each miss. Pure function of module state: no I/O, safe to call twice.
*/
export function assertValidationPipeSeesDtoDecorators(): void {
const storage = getMetadataStorage();
const missing: string[] = [];
for (const dto of PIPE_GUARDED_DTOS) {
// class-validator records constraints keyed on the DTO's constructor
// (decorators run on the class), and its executor resolves them via
// object.constructor. A property with no recorded metadata is invisible
// to the whitelist — whatever the cause — and fails here.
// Signature mirrors ValidationExecutor.js:50 — (constructor, schema, always,
// strictGroups, groups?). No schema, always=true, no groups: every
// constraint regardless of grouping, which is what the whitelist sees.
const metadatas = storage.getTargetValidationMetadatas(dto.target, '', true, false);
const decorated = new Set(metadatas.map((m) => m.propertyName));
for (const property of dto.properties) {
if (!decorated.has(property)) {
missing.push(
`${dto.name}.${property} has no class-validator constraints visible to the pipe`,
);
}
}
}
if (missing.length > 0) {
throw new PipeMetatypeCheckError(missing);
}
}
+242 -1014
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
---
kind: spec
status: active
---
# Mosaic Stack Roadmap
Companion to [docs/PRD.md](./PRD.md). Governed by the D11 rule: **every planned
phase appears here from day one, even as a placeholder** — nothing exists only
in heads. A phase marked _placeholder_ is a commitment to design it, not a
design; scoping one requires its own PRD section or requirements doc plus
review.
Phases are product phases. The in-flight platform workstreams (KBN-100/101
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, and the other
Part II contracts in the PRD) run as parallel tracks under their own issues
and are prerequisites where noted.
| Phase | Scope | Status |
| ----- | ------------------------------------------------------------------------------ | ----------------------------------------- |
| P0 | Current state on `next`: read-only dashboard, chat, auth/SSO login, admin tabs | shipped, evolving |
| P1 | **v1 slice** (PRD Part I §9) | next up |
| P2 | Connectors + comms + wizard expansion | placeholder |
| P3 | Full onboarding profile + M365 | placeholder |
| P4 | Enterprise mode + one-way conversion | placeholder |
| P5 | Federation | placeholder (deliberately undesigned, D3) |
## P0 — current state
What exists on `next` today: web dashboard (login/register/SSO, chat,
read-only projects/tasks, settings, admin user/system-health tabs), the
Gateway, the CLI-first framework tooling, and the fleet control plane. The
webUI audit (USC estate, webui-audit lane) measures the gap between this and
P1.
## P1 — v1 slice (D11)
1. Standalone onboarding wizard: system/company name, component choices,
initial user, initial estate + project, seeded examples, re-runnable.
2. Hierarchy core: company → estate → project → workspace → kanban, read-only
task bubble-up (kanban SOT Amendment A1 is the schema contract).
3. Basic RBAC on the hierarchy.
4. Minimal agent enrollment: one harness, API key, name/persona.
Prerequisites: KBN-100/101 schema foundation; the D8 tool inventory and
webUI→tool mapping (any missing tool is built first, D12).
## P2 — connectors + comms + wizard expansion (placeholder)
Email and drive connectors (Gmail/IMAP, Google Drive/OneDrive/Dropbox) with
granular agentic-access consent; comms integrations (Matrix/Discord/Slack)
including agent auto-enroll. Wizard gains the corresponding tabs (D4), plus
the D4 capabilities deferred out of P1's minimal slice: expanded agent
enrollment (OAuth login, multi-account, model choice with recommendation,
account assignment, comms auto-enroll) and the Standalone SSO/OIDC
configuration tab.
## P3 — full onboarding profile + M365 (placeholder)
Complete user onboarding profile (communication-style capture, optional
voice-matching interview) under the D14 custody rule; M365 connectors,
available to both deployment modes as ordinary connectors (same consent model
as the P2 connector class). The Enterprise install flow's M365 prominence
(D4) arrives with the Enterprise phase, P4.
## P4 — Enterprise mode + conversion (placeholder)
Enterprise install flow (org chart, RBAC focus, immediate OIDC, SSO
prominent); per-user brains with architectural isolation (D14); Vault
required; the one-way Standalone → Enterprise conversion (D3).
## P5 — federation (placeholder)
Connecting deployments: system-level config, assigned users, rights and
data-access control, trusts with boundaries, strict data access, exfiltration
monitoring. Explicitly not designed yet (D3); nothing in earlier phases may
foreclose it. Requires its own PRD + threat model before any scoping.
File diff suppressed because it is too large Load Diff
+84
View File
@@ -372,3 +372,87 @@ The P0P3 canon does not authorize:
## 7. Global release evidence
P0P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence.
## 8. Amendment A1 — hierarchy parentage and RBAC chain above workspaces
**Status:** amendment to the ratified canon, added by reviewed PR under
decision D13 (operator ruling, 2026-08-25; decision owner Jason). It adds
parent structure ABOVE workspaces. Sections 17, every invariant in §3, and
every REQ above remain binding verbatim, with exactly one express modification:
the narrow portfolio-analytics carve-out stated in §8.2.4. Nothing else below
this line is weakened.
### 8.1 What is added
1. A platform hierarchy exists above workspaces:
**company/organization → estate → platform-project → workspace**. Each
workspace belongs to exactly one platform-project, each platform-project to
exactly one estate, each estate to exactly one company.
2. **Record class.** Hierarchy records (company, estate, platform-project,
their parentage edges, and hierarchy-level access grants) are a new,
explicitly named record class: **tenancy/authorization structure records**.
They are not business or orchestration records, so §3 invariant 10 and
REQ-TEN-001 do not apply to them and are not weakened by them — those two
requirements bind business/orchestration rows exactly as before.
Constraints on the new class:
- Hierarchy tables MUST NOT carry task, plan, or any other
business/orchestration payload — parentage, naming, and grant data only.
- A hierarchy record can never be the subject of work: it cannot be
claimed, ordered, gated, or referenced as a dependency by any
business/orchestration row.
- Hierarchy mutations flow through the same sole-writable-SOT, fail-closed,
audited mutation path as everything else (§8.2.3).
3. The hierarchy serves exactly two runtime functions, plus audited
maintenance of its own structure:
- **RBAC evaluation:** access grants are declared per company, estate, or
platform-project and evaluate down the chain to workspace-scoped
authorization. Tenant context continues to be derived from authenticated
authority (REQ-TEN-001); the chain adds where grants can be declared,
not a bypass of workspace authorization.
- **Read-only roll-ups:** task and status visualization bubbles up the
hierarchy as aggregation over workspaces the reader is authorized on.
- **Chain maintenance (not a third runtime function):** re-parenting an
asset — moving a workspace to another platform-project, a
platform-project to another estate, and so on ("assets are transferable
subject to the structure", PRD Part I §4) — is an audited edit of the
hierarchy records themselves under §8.3. It never modifies
business/orchestration rows and never crosses a workspace boundary for
them; the workspace's contents move with the workspace untouched.
4. Naming: this amendment says **platform-project** for the hierarchy level
above workspaces, because §5 REQ-PLAN-001 already defines `projects` as
planning entities INSIDE a workspace. The two are different objects. Final
terminology (rename of one or the other) is an implementation-PR decision
under this amendment's review; the schema MUST NOT merge them.
### 8.2 What is explicitly unchanged
1. `workspace_id` remains the hard mechanical isolation unit (§2 D2,
REQ-TEN-001). Hierarchy tables carry parentage; they do not create
cross-workspace relationships between business/orchestration rows, which
remain rejected (§3 invariant 10).
2. Roll-up is **never a write**: no aggregation path may mutate, claim, order,
or gate work in any workspace. Bubble-up views are generated projections in
the sense of §3 invariant 5 — non-authoritative and never import sources.
3. Fail-closed mutation health (§3 invariants 34), sole writable PostgreSQL
SOT, fencing, audit, and the Coordinator/Certifier authority rules are
untouched.
4. No §6 non-goal is authorized, with one express, narrow carve-out that this
amendment makes to the "portfolio analytics" non-goal: the read-only
roll-up of §8.1 — per-workspace task counts and statuses aggregated up the
parent chain, over workspaces the reader is authorized on — is in scope.
Everything beyond that boundary (metrics, trends, forecasting, scoring,
dashboards computed across workspaces, any derived analytic that is not a
direct count/status aggregation) remains a non-goal. This is an explicit
narrowing by amendment, not a claim that §6 is unchanged; every other §6
non-goal is untouched.
### 8.3 Acceptance (binding on the implementing PRs)
- Schema tests prove each workspace resolves to exactly one
platform-project/estate/company chain and that chain edits are audited.
- Authorization tests prove a grant at each hierarchy level yields exactly the
workspace permissions the chain implies, and that revocation up the chain
propagates.
- Negative tests prove roll-up endpoints cannot mutate state and that a
reader sees aggregates only over workspaces they are authorized on
(no cross-tenant existence oracles).
+10 -4
View File
@@ -172,9 +172,10 @@ export function registerGatewayCommand(program: Command): void {
.command('recover-token')
.description('Recover an admin token — prompts for login if no valid session exists')
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
.action(async (cmdOpts: { gateway?: string }) => {
.option('-e, --email <email>', 'Headless: account email (password read from stdin line 2)')
.action(async (cmdOpts: { gateway?: string; email?: string }) => {
const { runRecoverToken } = await import('./gateway/token-ops.js');
await runRecoverToken(cmdOpts.gateway);
await runRecoverToken(cmdOpts.gateway, cmdOpts.email);
});
// ─── logs ───────────────────────────────────────────────────────────────
@@ -202,9 +203,14 @@ export function registerGatewayCommand(program: Command): void {
gw.command('uninstall')
.description('Uninstall the gateway daemon and optionally remove data')
.action(async () => {
.option(
'-y, --yes',
'Headless: skip the confirmation prompt (required when stdin is not a TTY)',
)
.option('--remove-data', 'Also remove all gateway data (never implied by --yes)')
.action(async (cmdOpts: { yes?: boolean; removeData?: boolean }) => {
const { runUninstall } = await import('./gateway/uninstall.js');
await runUninstall();
await runUninstall(cmdOpts);
});
// ─── doctor ─────────────────────────────────────────────────────────────────
@@ -95,11 +95,17 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
// fatal (#1392): an install that reports success over an empty/partial
// database is the exact T63 failure this command must never reproduce.
let verifyResult: VerifyResult | undefined;
let verificationThrew = false;
try {
const { runPostInstallVerification } = await import('./verify.js');
verifyResult = await runPostInstallVerification(configResult.host, configResult.port);
} catch {
// Non-fatal — verification is a courtesy
} catch (err) {
// Health/token/bootstrap courtesy failures are non-fatal, but a THROWN
// schema verification must not let install report success either (N2,
// rev-code-02 review 285): mark it and treat as fatal below.
verificationThrew = true;
const msg = err instanceof Error ? err.message : String(err);
prompter.warn(`Post-install verification errored: ${msg}`);
}
if (verifyResult && verifyResult.schemaMigrated === false) {
prompter.warn(
@@ -107,6 +113,12 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
);
process.exit(1);
}
if (verificationThrew) {
prompter.warn(
'Gateway install ABORTED: post-install verification errored (see above); refusing to report success on an unverified database.',
);
process.exit(1);
}
} catch (err) {
// Stages normally return structured results for expected failures.
// Anything that reaches here is an unexpected runtime error — render a
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { Readable } from 'node:stream';
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
describe('readCredentialsFromPipedStdin — #1394 stdin dual path (real streams)', () => {
it('reads exactly two lines; email trimmed, password as-is', async () => {
const r = await readCredentialsFromPipedStdin(
Readable.from([' [email protected] \n', 'pw with spaces \n']),
);
expect(r.email).toBe('[email protected]');
expect(r.password).toBe('pw with spaces ');
});
it('empty stdin → nulls (the headless-no-credentials shape)', async () => {
const r = await readCredentialsFromPipedStdin(Readable.from(['']));
expect(r).toEqual({ email: null, password: null });
});
it('single line only → email set, password null', async () => {
const r = await readCredentialsFromPipedStdin(Readable.from(['only-email\n']));
expect(r.email).toBe('only-email');
expect(r.password).toBeNull();
});
it('stops after two lines even if more follow', async () => {
const r = await readCredentialsFromPipedStdin(
Readable.from(['[email protected]\n', 'pw\n', 'extra\n', 'more\n']),
);
expect(r).toEqual({ email: '[email protected]', password: 'pw' });
});
});
@@ -0,0 +1,26 @@
import { createInterface } from 'node:readline';
/**
* Read email + password as two lines from non-TTY stdin (the headless dual
* path for callers that cannot pass argv: printf 'email\npassword\n' | …).
* Caller gates on !isTTY; the password line is kept as-is (no trim —
* whitespace may be intentional).
*
* Separate module (not login.ts) so tests can exercise the REAL reader
* against real streams while token-ops specs mock this seam cleanly.
*/
export function readCredentialsFromPipedStdin(
input: NodeJS.ReadableStream = process.stdin,
): Promise<{ email: string | null; password: string | null }> {
return new Promise((resolve) => {
const lines: string[] = [];
const rl = createInterface({ input });
rl.on('line', (l) => {
lines.push(l);
if (lines.length >= 2) rl.close();
});
rl.on('close', () => {
resolve({ email: (lines[0] ?? '').trim() || null, password: lines[1] ?? null });
});
});
}
@@ -16,11 +16,20 @@ vi.mock('./daemon.js', () => ({
vi.mock('./login.js', () => ({
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
// promptLine/promptSecret are used by ensureSession; return fixed values so tests don't block on stdin
// promptLine/promptSecret are used by ensureSession on the TTY path; return fixed
// values so tests never block on stdin.
promptLine: vi.fn().mockResolvedValue('[email protected]'),
promptSecret: vi.fn().mockResolvedValue('test-password'),
}));
// #1394: non-TTY runs resolve credentials from piped stdin instead of prompts.
vi.mock('./piped-credentials.js', () => ({
readCredentialsFromPipedStdin: vi.fn().mockResolvedValue({
email: '[email protected]',
password: 'test-password',
}),
}));
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
@@ -65,7 +74,7 @@ describe('ensureSession', () => {
expect(mockSignIn).not.toHaveBeenCalled();
});
it('prompts for credentials and signs in when stored session is invalid', async () => {
it('resolves piped-stdin credentials and signs in when stored session is invalid', async () => {
mockLoadSession.mockReturnValueOnce({ cookie: 'old-cookie', userId: 'u1', email: '[email protected]' });
mockValidateSession.mockResolvedValueOnce(false);
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
@@ -76,7 +85,7 @@ describe('ensureSession', () => {
expect(mockSaveSession).toHaveBeenCalledWith(baseUrl, newAuth);
});
it('prompts for credentials when no session exists', async () => {
it('resolves piped-stdin credentials when no session exists', async () => {
mockLoadSession.mockReturnValueOnce(null);
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
mockSignIn.mockResolvedValueOnce(newAuth);
@@ -84,6 +93,10 @@ describe('ensureSession', () => {
const cookie = await ensureSession(baseUrl);
expect(cookie).toBe(fakeCookie);
expect(mockSignIn).toHaveBeenCalled();
// The non-TTY path resolves credentials from the piped-stdin seam, not prompts.
expect(
vi.mocked(await import('./piped-credentials.js')).readCredentialsFromPipedStdin,
).toHaveBeenCalled();
});
it('exits non-zero when signIn fails', async () => {
@@ -111,7 +124,7 @@ describe('runRecoverToken', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('prompts for login, mints a token, and persists it when no session exists', async () => {
it('signs in via piped stdin, mints a token, and persists it when no session exists', async () => {
mockLoadSession.mockReturnValueOnce(null);
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
mockSignIn.mockResolvedValueOnce(newAuth);
@@ -153,4 +153,29 @@ describe('resolveSchemaCheckConfigPath', () => {
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
}
});
it('gives MOSAIC_CONFIG NO authority (N1, review 285): env never overrides file resolution', async () => {
const { resolveSchemaCheckConfigPath } = await import('./schema-check.js');
const daemonDir = mkdtempSync(join(tmpdir(), 'schema-check-env-'));
tmpDirs.push(daemonDir);
const daemonHome = join(daemonDir, '.config', 'mosaic', 'gateway');
mkdirSync(daemonHome, { recursive: true });
writeFileSync(join(daemonHome, 'mosaic.config.json'), JSON.stringify(LOCAL_CFG));
// A stale env var pointing at a DIFFERENT file must be ignored entirely:
const decoyDir = mkdtempSync(join(tmpdir(), 'schema-check-decoy-'));
tmpDirs.push(decoyDir);
const decoyPath = join(decoyDir, 'mosaic.config.json');
writeFileSync(decoyPath, JSON.stringify(STANDALONE_CFG));
const prevHome = process.env['HOME'];
vi.stubEnv('HOME', daemonDir);
vi.stubEnv('MOSAIC_CONFIG', decoyPath);
try {
const resolved = resolveSchemaCheckConfigPath();
expect(resolved).toBe(join(daemonHome, 'mosaic.config.json'));
expect(resolved).not.toBe(decoyPath);
} finally {
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
}
});
});
@@ -53,8 +53,11 @@ export const SCHEMA_FAIL_REMEDIATION = [
*/
export function resolveSchemaCheckConfigPath(explicit?: string): string | undefined {
if (explicit) return resolve(explicit);
// NOTE: no env-var candidate, deliberately. apps/gateway/src/env.ts gives env
// NO config authority (a stale MOSAIC_CONFIG could verify a database the
// daemon never reads — rev-code-02 review 285, note N1). Resolution order
// mirrors the daemon's file priorities only.
const candidates = [
process.env['MOSAIC_CONFIG'],
join(homedir(), '.config', 'mosaic', 'gateway', 'mosaic.config.json'), // daemon-written
resolve(process.cwd(), 'mosaic.config.json'),
join(homedir(), '.mosaic', 'mosaic.config.json'),
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../auth.js', () => ({
loadSession: vi.fn(),
validateSession: vi.fn(),
signIn: vi.fn(),
saveSession: vi.fn(),
}));
vi.mock('./login.js', () => ({
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
promptLine: vi.fn(),
promptSecret: vi.fn(),
}));
vi.mock('./piped-credentials.js', () => ({
readCredentialsFromPipedStdin: vi.fn(),
}));
vi.mock('./daemon.js', () => ({
readMeta: vi.fn(),
writeMeta: vi.fn(),
}));
import { ensureSession } from './token-ops.js';
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
import { promptLine, promptSecret } from './login.js';
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
const URL = 'http://localhost:14242';
function asNonTTY(): void {
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
}
describe('ensureSession — #1394 credential precedence (flag > piped stdin > prompt)', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(loadSession).mockReturnValue(null);
asNonTTY();
});
it('stored valid session wins; no credentials touched', async () => {
vi.mocked(loadSession).mockReturnValue({ cookie: 'SESS', email: '[email protected]' } as never);
vi.mocked(validateSession).mockResolvedValue(true);
await expect(ensureSession(URL)).resolves.toBe('SESS');
expect(signIn).not.toHaveBeenCalled();
});
it('flag email + stdin password: FLAG wins for email, stdin supplies the password', async () => {
vi.mocked(signIn).mockResolvedValue({ cookie: 'NEW', email: '[email protected]' } as never);
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
email: '[email protected]',
password: 'stdin-pw',
});
await ensureSession(URL, { email: '[email protected]' });
expect(signIn).toHaveBeenCalledWith(URL, '[email protected]', 'stdin-pw');
expect(promptLine).not.toHaveBeenCalled();
expect(promptSecret).not.toHaveBeenCalled();
});
it('stdin-only path (no flag): both credentials from piped lines', async () => {
vi.mocked(signIn).mockResolvedValue({ cookie: 'NEW2', email: '[email protected]' } as never);
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
email: '[email protected]',
password: 'spw',
});
await ensureSession(URL);
expect(signIn).toHaveBeenCalledWith(URL, '[email protected]', 'spw');
});
it('no credentials headless → exit(2) with --email guidance; signIn untouched', async () => {
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({ email: null, password: null });
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('EXIT');
}) as never);
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(ensureSession(URL)).rejects.toThrow('EXIT');
expect(exit).toHaveBeenCalledWith(2);
expect(err).toHaveBeenCalledWith(expect.stringContaining('--email'));
expect(signIn).not.toHaveBeenCalled();
exit.mockRestore();
err.mockRestore();
});
it('successful sign-in persists the session', async () => {
vi.mocked(signIn).mockResolvedValue({ cookie: 'C', email: '[email protected]' } as never);
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
email: '[email protected]',
password: 'pw',
});
await ensureSession(URL);
expect(saveSession).toHaveBeenCalledWith(URL, expect.anything());
});
});
@@ -1,6 +1,7 @@
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
import { readMeta, writeMeta } from './daemon.js';
import { getGatewayUrl, promptLine, promptSecret } from './login.js';
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
interface MintedToken {
id: string;
@@ -107,8 +108,24 @@ export async function requireSession(gatewayUrl: string): Promise<string> {
* Ensure a valid session for the gateway, prompting for credentials if needed.
* On sign-in failure, prints the error and exits non-zero.
* Returns the session cookie.
*
* Credential precedence when sign-in is needed (#1394):
* 1. explicit opts (--email flag; highest)
* 2. non-TTY stdin — first line email, second line password (headless dual
* path for callers without argv access: printf 'email\npassword\n' | …)
* 3. interactive prompt (TTY only)
*/
export async function ensureSession(gatewayUrl: string): Promise<string> {
export interface SessionCredentialOptions {
/** Email from an explicit flag (argv). Highest precedence. */
email?: string;
/** Password from an explicit source. Rare; passwords normally come via stdin/prompt. */
password?: string;
}
export async function ensureSession(
gatewayUrl: string,
opts: SessionCredentialOptions = {},
): Promise<string> {
// Try the stored session first
const session = loadSession(gatewayUrl);
if (session) {
@@ -119,10 +136,25 @@ export async function ensureSession(gatewayUrl: string): Promise<string> {
console.log(`No session found for ${gatewayUrl}. Please sign in.`);
}
// Prompt for credentials — password must not be echoed to the terminal
const email = await promptLine('Email: ');
// Do not trim password — it may contain intentional leading/trailing whitespace
const password = await promptSecret('Password: ');
let email = opts.email;
let password = opts.password;
if ((!email || !password) && !process.stdin.isTTY) {
const piped = await readCredentialsFromPipedStdin();
email = email ?? piped.email ?? undefined;
password = password ?? piped.password ?? undefined;
}
if (!email || !password) {
if (!process.stdin.isTTY) {
console.error(
'No valid session and no credentials available headlessly. Provide --email plus ' +
"a password line on stdin (printf 'email\\npassword\\n' | …), or run interactively.",
);
process.exit(2);
}
email = await promptLine('Email: ');
// Do not trim password — it may contain intentional leading/trailing whitespace
password = await promptSecret('Password: ');
}
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
console.error(err instanceof Error ? err.message : String(err));
@@ -146,11 +178,12 @@ export async function runRotateToken(gatewayUrl?: string): Promise<void> {
}
/**
* `mosaic gateway config recover-token` — prompts for login if no session exists.
* `mosaic gateway config recover-token` — signs in if no session exists.
* Passes the --email flag through to ensureSession (#1394 dual path).
*/
export async function runRecoverToken(gatewayUrl?: string): Promise<void> {
export async function runRecoverToken(gatewayUrl?: string, email?: string): Promise<void> {
const url = getGatewayUrl(gatewayUrl);
const cookie = await ensureSession(url);
const cookie = await ensureSession(url, { email });
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
const minted = await mintAdminToken(url, cookie, label);
persistToken(url, minted);
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mkdirSync } from 'node:fs';
vi.mock('./daemon.js', () => ({
GATEWAY_HOME: '/tmp/u-test-gateway-home',
getDaemonPid: vi.fn().mockReturnValue(null),
readMeta: vi.fn(),
stopDaemon: vi.fn(),
uninstallGatewayPackage: vi.fn(),
}));
import { runUninstall } from './uninstall.js';
import { readMeta, uninstallGatewayPackage } from './daemon.js';
describe('gateway uninstall — #1390 headless semantics', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('non-TTY without --yes FAILS LOUD (exit 1, nothing touched)', async () => {
vi.mocked(readMeta).mockReturnValue({
version: '0.0.7',
installedAt: '',
entryPoint: '',
host: 'localhost',
port: 14242,
});
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('EXIT');
}) as never);
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(runUninstall()).rejects.toThrow('EXIT');
expect(exit).toHaveBeenCalledWith(1);
expect(err).toHaveBeenCalledWith(expect.stringContaining('stdin is not a TTY'));
expect(uninstallGatewayPackage).not.toHaveBeenCalled();
exit.mockRestore();
err.mockRestore();
});
it('--yes proceeds headlessly WITHOUT removing data (never implied)', async () => {
const meta = {
version: '0.0.7',
installedAt: '',
entryPoint: '',
host: 'localhost',
port: 14242,
};
vi.mocked(readMeta).mockReturnValue(meta);
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
await runUninstall({ yes: true });
expect(uninstallGatewayPackage).toHaveBeenCalledTimes(1);
expect(log).toHaveBeenCalledWith(expect.stringContaining('Gateway data kept'));
log.mockRestore();
});
it('--yes --remove-data removes data headlessly', async () => {
vi.mocked(readMeta).mockReturnValue({
version: '0.0.7',
installedAt: '',
entryPoint: '',
host: 'localhost',
port: 14242,
});
mkdirSync('/tmp/u-test-gateway-home', { recursive: true }); // existsSync gate
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
await runUninstall({ yes: true, removeData: true });
expect(uninstallGatewayPackage).toHaveBeenCalledTimes(1);
expect(log).toHaveBeenCalledWith(expect.stringContaining('Gateway data removed'));
log.mockRestore();
});
it('no meta → clean no-op even with --yes', async () => {
vi.mocked(readMeta).mockReturnValue(null);
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
await runUninstall({ yes: true });
expect(log).toHaveBeenCalledWith('Gateway is not installed.');
expect(uninstallGatewayPackage).not.toHaveBeenCalled();
log.mockRestore();
});
});
@@ -8,30 +8,65 @@ import {
uninstallGatewayPackage,
} from './daemon.js';
export async function runUninstall(): Promise<void> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
export interface UninstallOptions {
/** Skip the confirmation prompt (headless/scripted uninstall). */
yes?: boolean;
/** Also remove all gateway data at GATEWAY_HOME (never implied by --yes). */
removeData?: boolean;
}
export async function runUninstall(opts: UninstallOptions = {}): Promise<void> {
const nonInteractive = Boolean(opts.yes) || process.env['MOSAIC_ASSUME_YES'] === '1';
// Non-TTY without explicit consent must FAIL LOUD, not quietly do nothing:
// the pre-fix behavior (prompt on a closed stdin → default No → exit 0,
// gateway untouched) reported success-by-silence to every scripted caller
// (#1390). An explicit refusal beats a silent no-op.
if (!nonInteractive && !process.stdin.isTTY) {
console.error(
'gateway uninstall: stdin is not a TTY and no --yes was given — refusing to ' +
'run an interactive uninstall headlessly (nothing was changed). ' +
'Use --yes (and --remove-data to also delete gateway data), or run from a terminal.',
);
process.exit(1);
}
const rl = nonInteractive
? null
: createInterface({ input: process.stdin, output: process.stdout });
try {
await doUninstall(rl);
await doUninstall(rl as NonNullable<typeof rl>, opts, nonInteractive);
} finally {
rl.close();
rl?.close();
}
}
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
function prompt(
rl: NonNullable<ReturnType<typeof createInterface>>,
question: string,
): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
}
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
async function doUninstall(
rl: ReturnType<typeof createInterface>,
opts: UninstallOptions,
nonInteractive: boolean,
): Promise<void> {
const meta = readMeta();
if (!meta) {
console.log('Gateway is not installed.');
return;
}
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
return;
if (nonInteractive) {
console.log(`Uninstalling Mosaic Gateway (--yes${opts.removeData ? ' --remove-data' : ''})...`);
} else {
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
return;
}
}
// Stop if running
@@ -45,13 +80,20 @@ async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void
}
}
// Remove config/data
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
if (removeData.toLowerCase() === 'y') {
// Remove config/data. Interactive: ask. Headless: only with the explicit
// flag — destructive recursion is never implied by --yes alone (#1390).
let removeData = Boolean(opts.removeData);
if (!nonInteractive) {
const answer = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
removeData = answer.toLowerCase() === 'y';
}
if (removeData) {
if (existsSync(GATEWAY_HOME)) {
rmSync(GATEWAY_HOME, { recursive: true, force: true });
console.log('Gateway data removed.');
}
} else {
console.log(`Gateway data kept at ${GATEWAY_HOME}.`);
}
// Uninstall npm package
@@ -101,7 +101,7 @@ export async function runPostInstallVerification(
const { runMigrations, getMigrationStatus } = await import('@mosaicstack/db');
const result = await checkDatabaseSchema(
{ runMigrations, getMigrationStatus },
process.env['MOSAIC_CONFIG'],
undefined, // resolver mirrors daemon file priorities; env has no config authority (N1)
);
if (result.status === 'ok') {
ok(result.detail);