Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a17362ec |
+4
-6
@@ -38,12 +38,10 @@ when:
|
||||
- event: push
|
||||
branch: main
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
|
||||
steps:
|
||||
install:
|
||||
|
||||
@@ -73,13 +73,6 @@ 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
|
||||
@@ -115,13 +108,6 @@ 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
|
||||
|
||||
+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
@@ -372,87 +372,3 @@ The P0–P3 canon does not authorize:
|
||||
## 7. Global release evidence
|
||||
|
||||
P0–P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence.
|
||||
|
||||
## 8. Amendment A1 — hierarchy parentage and RBAC chain above workspaces
|
||||
|
||||
**Status:** amendment to the ratified canon, added by reviewed PR under
|
||||
decision D13 (operator ruling, 2026-08-25; decision owner Jason). It adds
|
||||
parent structure ABOVE workspaces. Sections 1–7, every invariant in §3, and
|
||||
every REQ above remain binding verbatim, with exactly one express modification:
|
||||
the narrow portfolio-analytics carve-out stated in §8.2.4. Nothing else below
|
||||
this line is weakened.
|
||||
|
||||
### 8.1 What is added
|
||||
|
||||
1. A platform hierarchy exists above workspaces:
|
||||
**company/organization → estate → platform-project → workspace**. Each
|
||||
workspace belongs to exactly one platform-project, each platform-project to
|
||||
exactly one estate, each estate to exactly one company.
|
||||
2. **Record class.** Hierarchy records (company, estate, platform-project,
|
||||
their parentage edges, and hierarchy-level access grants) are a new,
|
||||
explicitly named record class: **tenancy/authorization structure records**.
|
||||
They are not business or orchestration records, so §3 invariant 10 and
|
||||
REQ-TEN-001 do not apply to them and are not weakened by them — those two
|
||||
requirements bind business/orchestration rows exactly as before.
|
||||
Constraints on the new class:
|
||||
- Hierarchy tables MUST NOT carry task, plan, or any other
|
||||
business/orchestration payload — parentage, naming, and grant data only.
|
||||
- A hierarchy record can never be the subject of work: it cannot be
|
||||
claimed, ordered, gated, or referenced as a dependency by any
|
||||
business/orchestration row.
|
||||
- Hierarchy mutations flow through the same sole-writable-SOT, fail-closed,
|
||||
audited mutation path as everything else (§8.2.3).
|
||||
3. The hierarchy serves exactly two runtime functions, plus audited
|
||||
maintenance of its own structure:
|
||||
- **RBAC evaluation:** access grants are declared per company, estate, or
|
||||
platform-project and evaluate down the chain to workspace-scoped
|
||||
authorization. Tenant context continues to be derived from authenticated
|
||||
authority (REQ-TEN-001); the chain adds where grants can be declared,
|
||||
not a bypass of workspace authorization.
|
||||
- **Read-only roll-ups:** task and status visualization bubbles up the
|
||||
hierarchy as aggregation over workspaces the reader is authorized on.
|
||||
- **Chain maintenance (not a third runtime function):** re-parenting an
|
||||
asset — moving a workspace to another platform-project, a
|
||||
platform-project to another estate, and so on ("assets are transferable
|
||||
subject to the structure", PRD Part I §4) — is an audited edit of the
|
||||
hierarchy records themselves under §8.3. It never modifies
|
||||
business/orchestration rows and never crosses a workspace boundary for
|
||||
them; the workspace's contents move with the workspace untouched.
|
||||
4. Naming: this amendment says **platform-project** for the hierarchy level
|
||||
above workspaces, because §5 REQ-PLAN-001 already defines `projects` as
|
||||
planning entities INSIDE a workspace. The two are different objects. Final
|
||||
terminology (rename of one or the other) is an implementation-PR decision
|
||||
under this amendment's review; the schema MUST NOT merge them.
|
||||
|
||||
### 8.2 What is explicitly unchanged
|
||||
|
||||
1. `workspace_id` remains the hard mechanical isolation unit (§2 D2,
|
||||
REQ-TEN-001). Hierarchy tables carry parentage; they do not create
|
||||
cross-workspace relationships between business/orchestration rows, which
|
||||
remain rejected (§3 invariant 10).
|
||||
2. Roll-up is **never a write**: no aggregation path may mutate, claim, order,
|
||||
or gate work in any workspace. Bubble-up views are generated projections in
|
||||
the sense of §3 invariant 5 — non-authoritative and never import sources.
|
||||
3. Fail-closed mutation health (§3 invariants 3–4), sole writable PostgreSQL
|
||||
SOT, fencing, audit, and the Coordinator/Certifier authority rules are
|
||||
untouched.
|
||||
4. No §6 non-goal is authorized, with one express, narrow carve-out that this
|
||||
amendment makes to the "portfolio analytics" non-goal: the read-only
|
||||
roll-up of §8.1 — per-workspace task counts and statuses aggregated up the
|
||||
parent chain, over workspaces the reader is authorized on — is in scope.
|
||||
Everything beyond that boundary (metrics, trends, forecasting, scoring,
|
||||
dashboards computed across workspaces, any derived analytic that is not a
|
||||
direct count/status aggregation) remains a non-goal. This is an explicit
|
||||
narrowing by amendment, not a claim that §6 is unchanged; every other §6
|
||||
non-goal is untouched.
|
||||
|
||||
### 8.3 Acceptance (binding on the implementing PRs)
|
||||
|
||||
- Schema tests prove each workspace resolves to exactly one
|
||||
platform-project/estate/company chain and that chain edits are audited.
|
||||
- Authorization tests prove a grant at each hierarchy level yields exactly the
|
||||
workspace permissions the chain implies, and that revocation up the chain
|
||||
propagates.
|
||||
- Negative tests prove roll-up endpoints cannot mutate state and that a
|
||||
reader sees aggregates only over workspaces they are authorized on
|
||||
(no cross-tenant existence oracles).
|
||||
|
||||
@@ -172,10 +172,9 @@ 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)')
|
||||
.option('-e, --email <email>', 'Headless: account email (password read from stdin line 2)')
|
||||
.action(async (cmdOpts: { gateway?: string; email?: string }) => {
|
||||
.action(async (cmdOpts: { gateway?: string }) => {
|
||||
const { runRecoverToken } = await import('./gateway/token-ops.js');
|
||||
await runRecoverToken(cmdOpts.gateway, cmdOpts.email);
|
||||
await runRecoverToken(cmdOpts.gateway);
|
||||
});
|
||||
|
||||
// ─── logs ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -95,17 +95,11 @@ 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 (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}`);
|
||||
} catch {
|
||||
// Non-fatal — verification is a courtesy
|
||||
}
|
||||
if (verifyResult && verifyResult.schemaMigrated === false) {
|
||||
prompter.warn(
|
||||
@@ -113,12 +107,6 @@ 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
|
||||
|
||||
@@ -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', () => ({
|
||||
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
|
||||
// promptLine/promptSecret are used by ensureSession on the TTY path; return fixed
|
||||
// values so tests never block on stdin.
|
||||
// promptLine/promptSecret are used by ensureSession; return fixed values so tests don't 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);
|
||||
|
||||
@@ -74,7 +65,7 @@ describe('ensureSession', () => {
|
||||
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]' });
|
||||
mockValidateSession.mockResolvedValueOnce(false);
|
||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||
@@ -85,7 +76,7 @@ describe('ensureSession', () => {
|
||||
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);
|
||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||
mockSignIn.mockResolvedValueOnce(newAuth);
|
||||
@@ -93,10 +84,6 @@ 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 () => {
|
||||
@@ -124,7 +111,7 @@ describe('runRecoverToken', () => {
|
||||
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);
|
||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||
mockSignIn.mockResolvedValueOnce(newAuth);
|
||||
|
||||
@@ -153,29 +153,4 @@ 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,11 +53,8 @@ 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'),
|
||||
|
||||
@@ -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 { readMeta, writeMeta } from './daemon.js';
|
||||
import { getGatewayUrl, promptLine, promptSecret } from './login.js';
|
||||
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
|
||||
|
||||
interface MintedToken {
|
||||
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.
|
||||
* 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 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> {
|
||||
export async function ensureSession(gatewayUrl: string): Promise<string> {
|
||||
// Try the stored session first
|
||||
const session = loadSession(gatewayUrl);
|
||||
if (session) {
|
||||
@@ -136,25 +119,10 @@ export async function ensureSession(
|
||||
console.log(`No session found for ${gatewayUrl}. Please sign in.`);
|
||||
}
|
||||
|
||||
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: ');
|
||||
}
|
||||
// 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: ');
|
||||
|
||||
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
|
||||
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.
|
||||
* Passes the --email flag through to ensureSession (#1394 dual path).
|
||||
* `mosaic gateway config recover-token` — prompts for login if no session exists.
|
||||
*/
|
||||
export async function runRecoverToken(gatewayUrl?: string, email?: string): Promise<void> {
|
||||
export async function runRecoverToken(gatewayUrl?: string): Promise<void> {
|
||||
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 minted = await mintAdminToken(url, cookie, label);
|
||||
persistToken(url, minted);
|
||||
|
||||
@@ -101,7 +101,7 @@ export async function runPostInstallVerification(
|
||||
const { runMigrations, getMigrationStatus } = await import('@mosaicstack/db');
|
||||
const result = await checkDatabaseSchema(
|
||||
{ runMigrations, getMigrationStatus },
|
||||
undefined, // resolver mirrors daemon file priorities; env has no config authority (N1)
|
||||
process.env['MOSAIC_CONFIG'],
|
||||
);
|
||||
if (result.status === 'ok') {
|
||||
ok(result.detail);
|
||||
|
||||
Reference in New Issue
Block a user