Compare commits

..
Author SHA1 Message Date
fargo 8eb8e7cfce Merge remote-tracking branch 'origin/next' into fix/1280-identity-first-resolution
ci/woodpecker/pr/ci Pipeline was successful
# Conflicts:
#	packages/mosaic/package.json
2026-08-17 16:24:43 -05:00
fargo d789a43cae test(git): hermetic fixtures for issue-create harnesses (#1282-#1287)
ci/woodpecker/pr/ci Pipeline was canceled
test-issue-create-body-safety.sh and test-issue-create-interactive-auth.sh
inherited the seat's real HOME and global git config. With the #1280 fix
activating identity mode BEFORE the tea path, a workstation-global
mosaic.gitIdentity resolved inside the fixture repo, and the wrapper's
API fallback posted to the LIVE forge with a real per-slot token — six
real issues (#1282-#1287, authored mos-dt-0, closed with provenance by
fred within the hour).

Neutralize the source the resolver actually reads, and prove it by making
the resolution fail. A control that does not make the thing fail has not
been shown to control it. The earlier attempted neutralization pinned
MOSAIC_CREDENTIALS_FILE to a fake — a real guard aimed at an adjacent
input: the identity arm reads the per-slot token file directly and never
consults credentials.json. Hence env -i with a fake HOME and
GIT_CONFIG_GLOBAL=/dev/null (severing the global identity) rather than
one more targeted variable, plus a curl tripwire stub in the body-safety
harness so ANY provider request is a loud test failure instead of a live
write.
2026-08-17 15:19:04 -05:00
fargo 19ad93999f fix(git): identity-first principal resolution across write wrappers (#1280)
MOSAIC_GIT_IDENTITY=fargo produced objects attributed to mos-dt-0: every
write wrapper resolved its acting principal from tea's login list, which
enumerates whatever logins the host happens to hold and knows nothing
about which seat is calling. The identity-aware code was present and
correct but unreachable on the happy path — it sat on arms that only ran
when tea failed.

One shared resolver, not twenty patches: resolve_gitea_principal() in
detect-platform.sh implements the precedence (explicit --login beats
MOSAIC_GIT_IDENTITY / worktree git config mosaic.gitIdentity; the tea
login list is the LAST resort), fails loud (nonzero, naming the identity
or login and the expected slot path) when the requested principal has no
credential, and never prints a token value. gitea_identity_token_slot()
is the single source of truth for the slot layout, shared with
get_gitea_token, so resolver and token resolution cannot disagree.

Call-site conversions (the proving five): pr-review.sh (principal
resolved once for every action; the comment action now honors --login),
issue-comment.sh, pr-create.sh (identity mode reaches the REST API on the
HAPPY path — tea is never consulted, so the login list cannot shadow the
identity; --login wins even on the tea-failure fallback arm),
issue-create.sh (same), pr-merge.sh (gains --login; --dry-run reports the
principal the merge WOULD act as, resolved exactly as the merge resolves
it; no cross-principal fallback — an identity-bound 401 is a hard stop).

Remaining wrappers are call-site conversions onto the same resolver,
measured: write-path issue-assign, issue-close, issue-edit, issue-reopen,
milestone-close, milestone-create, pr-close; read-path issue-list,
milestone-list, pr-list, pr-view (issue-view mixed). pr-diff, pr-metadata,
pr-ci-wait and ci-queue-wait already inherit identity-first resolution
via get_gitea_token.

Known interaction: on a host with a workstation-GLOBAL mosaic.gitIdentity,
this fix activates identity mode for every seat that has not set a local
one — correct behavior driven by a wrong configuration (measured:
#1282-#1287, six accidental live issues, closed with provenance by fred).
Set mosaic.gitIdentity per-worktree, never --global.

Tests: test-gitea-principal-resolution.sh (resolver matrix — identity
present/absent, --login precedence, env vs git-config, unrecognized-host
containment, slot-path-by-path-never-by-value); test-pr-create-identity-
first.sh (the load-bearing ordering test: identity arm REACHED on the
happy path with tea never invoked, fail-loud BEFORE any write on a
missing slot, --login wins, default preserved); test-pr-merge-principal-
resolution.sh (dry-run truthfulness, merge credential binding, unknown
--login never reaches the provider). All wired into test:framework-shell.

Sabotage control: precedence inverted to tea-list-first inside the
resolver -> exactly the three new suites redden with the #1280
signatures (identity resolves to the tea-list account; missing slot
returns rc=0 with silent fallthrough) while all 11 pre-existing git
suites stay green; restored byte-identical (sha256 verified); all 14
green again.
2026-08-17 15:18:54 -05:00
28 changed files with 1379 additions and 2639 deletions
@@ -1,110 +0,0 @@
'use client';
import type { ReactElement } from 'react';
import { formatAge, type FreshnessLabel } from '@/lib/freshness/model';
/**
* Rendering rules for non-current freshness states (RI-5-001).
*
* - `unavailable` renders an explicit failure panel — never an empty
* healthy collection.
* - `stale` may render last-known data, but only under a visible label
* carrying source identity, snapshot version, and age.
* - `partial` renders the verified parts plus an explicit list of what is
* missing.
*/
interface RetryableNoticeProps {
readonly onRetry?: () => void;
readonly retryLabel?: string;
}
function RetryButton({ onRetry, retryLabel }: RetryableNoticeProps): ReactElement | null {
if (!onRetry) return null;
return (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg border border-surface-border px-3 py-1.5 text-xs transition-colors hover:border-gray-500"
>
{retryLabel ?? 'Retry'}
</button>
);
}
export interface UnavailableDataNoticeProps extends RetryableNoticeProps {
/** What is unavailable, e.g. "Tasks". */
readonly title: string;
/** Optional underlying failure detail (network message, invalidation reason). */
readonly detail?: string | null;
}
/** Explicit `unavailable` state. Never renders as an empty healthy collection. */
export function UnavailableDataNotice({
title,
detail,
onRetry,
retryLabel,
}: UnavailableDataNoticeProps): ReactElement {
return (
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
<p className="font-medium text-text-primary">{title} are unavailable</p>
<p className="mt-1 text-text-muted">
This is not an empty result the data could not be verified from the gateway.
{detail ? ` ${detail}` : ''}
</p>
<RetryButton onRetry={onRetry} retryLabel={retryLabel} />
</div>
);
}
export interface StaleDataNoticeProps extends RetryableNoticeProps {
/** Provenance of the last-known snapshot being displayed. */
readonly label: FreshnessLabel;
}
/**
* Situational-awareness banner for `stale` data: last-known data may render,
* but visibly labeled with source identity, snapshot version, and age.
*/
export function StaleDataNotice({
label,
onRetry,
retryLabel,
}: StaleDataNoticeProps): ReactElement {
return (
<div role="status" className="rounded-lg border border-warning/40 px-4 py-3 text-sm">
<p className="font-medium text-warning">Showing last-known data it may be out of date</p>
<p className="mt-1 text-xs text-text-muted">
Source {label.source} · snapshot v{label.version} · fetched{' '}
{formatAge(label.fetchedAt, Date.now())}. Verdicts derived from this data are unknown and
changes are disabled until it is revalidated.
</p>
<RetryButton onRetry={onRetry} retryLabel={retryLabel ?? 'Revalidate'} />
</div>
);
}
export interface PartialDataNoticeProps extends RetryableNoticeProps {
/** Display names of the sections whose collections are unavailable. */
readonly missing: readonly string[];
}
/** `partial` surface banner: verified parts render, missing parts are explicit. */
export function PartialDataNotice({
missing,
onRetry,
retryLabel,
}: PartialDataNoticeProps): ReactElement {
return (
<div role="status" className="rounded-lg border border-warning/40 px-4 py-3 text-sm">
<p className="font-medium text-warning">Some data could not be loaded</p>
<p className="mt-1 text-xs text-text-muted">
{missing.join(', ')} {missing.length === 1 ? 'is' : 'are'} unavailable sections below show
an explicit unavailable state instead of an empty list. Derived verdicts remain unknown
until every collection is revalidated.
</p>
<RetryButton onRetry={onRetry} retryLabel={retryLabel ?? 'Revalidate'} />
</div>
);
}
-324
View File
@@ -1,324 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { Task } from '@/lib/types';
import {
acceptSnapshot,
assertMutable,
canMutate,
combineFreshness,
computeDigest,
computeFreshness,
DEFAULT_FRESHNESS_POLICY,
formatAge,
type FreshSnapshot,
invalidationReasonLabels,
StaleMutationError,
UNKNOWN_VERDICT,
verdictValue,
} from './model';
import { validateProjectCollection, validateTaskCollection } from './validators';
const NOW = 1_800_000_000_000;
const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 };
const taskPayload: Task[] = [
{
id: 'task-1',
title: 'T1',
description: null,
status: 'not-started',
priority: 'high',
projectId: 'project-1',
missionId: null,
assignee: null,
tags: null,
dueDate: null,
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
];
function acceptedTaskSnapshot(
overrides: Partial<FreshSnapshot<typeof taskPayload>> = {},
): FreshSnapshot<typeof taskPayload> {
const result = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
if (result.outcome !== 'accepted') {
throw new Error(`fixture setup failed: ${result.reason}`);
}
return { ...result.snapshot, ...overrides };
}
describe('computeFreshness', () => {
it('treats a missing snapshot as unavailable, never as an empty healthy collection', () => {
expect(computeFreshness({ snapshot: null, policy, now: NOW })).toBe('unavailable');
});
it('returns current for a fresh verified snapshot regardless of data emptiness', () => {
const empty = acceptSnapshot({
value: [],
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
if (empty.outcome !== 'accepted') throw new Error('expected acceptance');
expect(computeFreshness({ snapshot: empty.snapshot, policy, now: NOW })).toBe('current');
});
it('degrades to stale once the snapshot ages past staleAfterMs', () => {
const snapshot = acceptedTaskSnapshot();
expect(computeFreshness({ snapshot, policy, now: NOW + 60_001 })).toBe('stale');
expect(computeFreshness({ snapshot, policy, now: NOW + 59_999 })).toBe('current');
});
it('degrades to stale when the latest revalidation failed', () => {
const snapshot = acceptedTaskSnapshot();
expect(computeFreshness({ snapshot, policy, now: NOW, degraded: true })).toBe('stale');
});
});
describe('mutation guard', () => {
it('permits mutations only on current data', () => {
expect(canMutate('current')).toBe(true);
for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) {
expect(canMutate(state)).toBe(false);
}
});
it('refuses mutations on non-current data via assertMutable', () => {
expect(() => assertMutable('current')).not.toThrow();
for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) {
let thrown: unknown;
try {
assertMutable(state);
} catch (caught) {
thrown = caught;
}
expect(thrown).toBeInstanceOf(StaleMutationError);
expect(thrown).toBeInstanceOf(Error);
if (thrown instanceof StaleMutationError) {
expect(thrown.name).toBe('StaleMutationError');
expect(thrown.freshness).toBe(state);
expect(thrown.message).toContain(state);
expect(thrown.message).toContain('revalidat');
}
}
});
});
describe('acceptSnapshot', () => {
it('accepts a valid payload with provenance', () => {
const result = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
expect(result.outcome).toBe('accepted');
if (result.outcome !== 'accepted') return;
expect(result.snapshot.source).toBe('gateway:/api/tasks');
expect(result.snapshot.version).toBe(1);
expect(result.snapshot.fetchedAt).toBe(NOW);
expect(result.snapshot.data).toEqual(taskPayload);
});
it('invalidates a schema-mismatched payload instead of rendering it', () => {
const result = acceptSnapshot({
value: { not: 'an array' },
validate: validateTaskCollection,
previous: acceptedTaskSnapshot(),
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
expect(result).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' });
expect(invalidationReasonLabels['schema-mismatch']).toContain('schema');
});
it('invalidates cross-workspace payloads', () => {
const userOne = acceptSnapshot({
value: [
{
id: 'p1',
name: 'P1',
description: null,
status: 'active',
userId: 'user-1',
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
],
validate: validateProjectCollection,
previous: null,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
if (userOne.outcome !== 'accepted') throw new Error('expected acceptance');
const switched = acceptSnapshot({
value: [
{
id: 'p9',
name: 'P9',
description: null,
status: 'active',
userId: 'user-2',
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
],
validate: validateProjectCollection,
previous: userOne.snapshot,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
expect(switched).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
});
it('keeps the previous workspace for collections with no intrinsic identity', () => {
const userOne = acceptSnapshot({
value: [
{
id: 'p1',
name: 'P1',
description: null,
status: 'active',
userId: 'user-1',
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
],
validate: validateProjectCollection,
previous: null,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
if (userOne.outcome !== 'accepted') throw new Error('expected acceptance');
// Empty list after the user deleted every project: no identity to check,
// so the verified scope is retained and the empty state stays healthy.
const emptied = acceptSnapshot({
value: [],
validate: validateProjectCollection,
previous: userOne.snapshot,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
expect(emptied.outcome).toBe('accepted');
if (emptied.outcome === 'accepted') {
expect(emptied.snapshot.data).toEqual([]);
expect(emptied.snapshot.workspace).toBe('user-1');
}
});
it('invalidates version regressions', () => {
const previous = acceptedTaskSnapshot({ version: 7 });
const regressed = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous,
policy,
source: 'gateway:/api/tasks',
now: NOW,
incomingVersion: 3,
});
expect(regressed).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
const newerSchema = acceptedTaskSnapshot({ schemaVersion: 4 });
const downgradedClient = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: newerSchema,
policy: { ...policy, schemaVersion: 2 },
source: 'gateway:/api/tasks',
now: NOW,
});
expect(downgradedClient).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
});
it('increments the version monotonically across accepted snapshots', () => {
const first = acceptedTaskSnapshot();
const second = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: first,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
expect(second.outcome).toBe('accepted');
if (second.outcome === 'accepted') {
expect(second.snapshot.version).toBe(first.version + 1);
}
});
});
describe('combineFreshness', () => {
it('gates the surface on the primary collection', () => {
expect(combineFreshness('unavailable', ['current'])).toBe('unavailable');
expect(combineFreshness('unknown', ['current'])).toBe('unknown');
expect(combineFreshness('current', [])).toBe('current');
});
it('degrades to partial when a secondary is unavailable', () => {
expect(combineFreshness('current', ['current', 'unavailable'])).toBe('partial');
});
it('degrades to unknown while a secondary is still loading', () => {
expect(combineFreshness('current', ['unknown'])).toBe('unknown');
});
it('degrades to stale when any collection is stale', () => {
expect(combineFreshness('current', ['stale'])).toBe('stale');
expect(combineFreshness('stale', ['current'])).toBe('stale');
});
it('propagates partial secondaries', () => {
expect(combineFreshness('current', ['partial'])).toBe('partial');
});
});
describe('computeDigest', () => {
it('is stable across key order and changes with data', () => {
const a = computeDigest({ x: 1, y: [1, 2] });
const b = computeDigest({ y: [1, 2], x: 1 });
expect(a).toBe(b);
expect(computeDigest({ x: 1, y: [1, 3] })).not.toBe(a);
});
});
describe('verdictValue', () => {
it('returns the value only for verified inputs', () => {
expect(verdictValue(true, '5')).toBe('5');
expect(verdictValue(false, '5')).toBe(UNKNOWN_VERDICT);
expect(verdictValue(false, '5')).not.toBe('5');
});
});
describe('formatAge', () => {
it('labels age in human terms', () => {
expect(formatAge(NOW, NOW)).toBe('just now');
expect(formatAge(NOW, NOW + 15_000)).toBe('under a minute ago');
expect(formatAge(NOW, NOW + 120_000)).toBe('2m ago');
expect(formatAge(NOW, NOW + 3 * 3_600_000)).toBe('3h ago');
expect(formatAge(NOW, NOW + 2 * 86_400_000)).toBe('2d ago');
});
});
-261
View File
@@ -1,261 +0,0 @@
/**
* Typed freshness model for gateway-fetched collections (RI-5-001).
*
* A failed or stale fetch must never be indistinguishable from an empty
* healthy collection. Every fetched surface carries an explicit freshness
* state, a verified snapshot identity (source, workspace, version, age), and
* a mutation guard that refuses state-changing operations unless the data is
* verified current.
*/
/** Freshness states for fetched data. Never inferred from emptiness. */
export type FreshnessState = 'current' | 'stale' | 'partial' | 'unknown' | 'unavailable';
/**
* Reasons a snapshot is invalidated. An invalidated snapshot is treated as
* unavailable and is never rendered as current.
*/
export type InvalidationReason =
| 'cache-corruption'
| 'cross-workspace'
| 'schema-mismatch'
| 'version-regression';
/** Human-readable labels for invalidation reasons (UI + error messages). */
export const invalidationReasonLabels: Record<InvalidationReason, string> = {
'cache-corruption': 'cached snapshot failed integrity checks',
'cross-workspace': 'data belongs to a different workspace',
'schema-mismatch': 'response did not match the expected schema',
'version-regression': 'snapshot version regressed below the accepted version',
};
/** A verified snapshot of fetched data with full provenance. */
export interface FreshSnapshot<T> {
readonly data: T;
/** Source identity of the fetch, e.g. `gateway:/api/tasks`. */
readonly source: string;
/** Workspace scope the data belongs to. */
readonly workspace: string;
/** Monotonic snapshot sequence number for this surface. */
readonly version: number;
/** Schema version of the validator that accepted this snapshot. */
readonly schemaVersion: number;
/** Epoch ms at which the data was verified. */
readonly fetchedAt: number;
/** Integrity digest of `data`, used to detect cache corruption. */
readonly digest: string;
}
/** Provenance label rendered next to last-known data. */
export interface FreshnessLabel {
readonly source: string;
readonly version: number;
readonly fetchedAt: number;
}
/** Policy governing freshness for a surface. */
export interface FreshnessPolicy {
/** Active workspace scope. Snapshots from other scopes are invalidated. */
readonly workspace: string;
/** Schema version of the current validator. */
readonly schemaVersion: number;
/** Age after which a verified snapshot degrades from current to stale. */
readonly staleAfterMs: number;
}
export const DEFAULT_FRESHNESS_POLICY: FreshnessPolicy = {
workspace: 'default',
schemaVersion: 1,
staleAfterMs: 60_000,
};
/** Payload returned by a successful schema validation. */
export interface FreshPayload<T> {
readonly data: T;
/**
* Workspace identity extracted from the payload itself when the collection
* carries one (e.g. a uniform `userId` on projects). `null` when the
* collection has no intrinsic workspace identity.
*/
readonly workspace: string | null;
}
/** Error thrown when a mutation is attempted on non-current data. */
export class StaleMutationError extends Error {
readonly freshness: FreshnessState;
constructor(freshness: FreshnessState) {
super(`Refused mutation on ${freshness} data: revalidation is required before mutating.`);
this.name = 'StaleMutationError';
this.freshness = freshness;
}
}
/** Stable JSON digest used for snapshot integrity checks. */
export function computeDigest(value: unknown): string {
// FNV-1a 32-bit over the stable JSON serialization. This is an integrity
// check against corruption, not a cryptographic guarantee.
let hash = 0x811c9dc5;
for (const byte of stableStringify(value)) {
hash ^= byte.charCodeAt(0);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
function stableStringify(value: unknown): string {
return serialize(value);
}
function serialize(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
if (Array.isArray(value)) return `[${value.map(serialize).join(',')}]`;
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([key, item]) => `${JSON.stringify(key)}:${serialize(item)}`);
return `{${entries.join(',')}}`;
}
export type AcceptSnapshotResult<T> =
| { readonly outcome: 'accepted'; readonly snapshot: FreshSnapshot<T> }
| { readonly outcome: 'invalidated'; readonly reason: InvalidationReason };
export interface AcceptSnapshotOptions<T> {
/** Raw fetched value (untrusted JSON). */
readonly value: unknown;
/** Schema validator; returns `null` when the value does not match. */
readonly validate: (value: unknown) => FreshPayload<T> | null;
/** Previously accepted snapshot for this surface, if any. */
readonly previous: FreshSnapshot<T> | null;
readonly policy: FreshnessPolicy;
readonly source: string;
/**
* Version carried by the incoming payload when the transport exposes one.
* Must not regress below the accepted snapshot's version.
*/
readonly incomingVersion?: number;
readonly now: number;
}
/**
* Validate and accept a fetched value as a snapshot, or invalidate it.
*
* Invalidation rules (each treated as unavailable, never rendered current):
* - schema mismatch: the payload fails validation
* - cross-workspace: the payload's workspace differs from the verified one
* - version regression: payload/schema version is below the accepted one
*/
export function acceptSnapshot<T>(options: AcceptSnapshotOptions<T>): AcceptSnapshotResult<T> {
const payload = options.validate(options.value);
if (payload === null) {
return { outcome: 'invalidated', reason: 'schema-mismatch' };
}
// Workspace identity: the payload's own scope wins; a collection with no
// intrinsic identity (e.g. an empty list after every project was deleted)
// keeps the previously verified scope rather than resetting to the policy
// default, so a legitimately empty response is not mistaken for a scope
// change.
const workspace = payload.workspace ?? options.previous?.workspace ?? options.policy.workspace;
if (options.previous !== null && options.previous.workspace !== workspace) {
return { outcome: 'invalidated', reason: 'cross-workspace' };
}
if (options.previous !== null && options.policy.schemaVersion < options.previous.schemaVersion) {
return { outcome: 'invalidated', reason: 'version-regression' };
}
if (
options.incomingVersion !== undefined &&
options.previous !== null &&
options.incomingVersion < options.previous.version
) {
return { outcome: 'invalidated', reason: 'version-regression' };
}
const snapshot: FreshSnapshot<T> = {
data: payload.data,
source: options.source,
workspace,
version: options.incomingVersion ?? (options.previous?.version ?? 0) + 1,
schemaVersion: options.policy.schemaVersion,
fetchedAt: options.now,
digest: computeDigest(payload.data),
};
return { outcome: 'accepted', snapshot };
}
export interface ComputeFreshnessOptions {
readonly snapshot: FreshSnapshot<unknown> | null;
readonly policy: FreshnessPolicy;
readonly now: number;
/**
* True when the snapshot cannot be trusted as current regardless of age:
* the latest revalidation failed, or the snapshot was restored from cache
* and has not been verified by a fetch in this session.
*/
readonly degraded?: boolean;
}
/**
* Compute the freshness state of a snapshot. A missing snapshot is
* `unavailable` (never "empty and healthy"); a degraded or aged snapshot is
* `stale` (situational awareness only).
*/
export function computeFreshness(options: ComputeFreshnessOptions): FreshnessState {
const { snapshot, policy, now, degraded = false } = options;
if (snapshot === null) return 'unavailable';
if (degraded) return 'stale';
if (now - snapshot.fetchedAt > policy.staleAfterMs) return 'stale';
return 'current';
}
/** Only verified-current data may back a state-changing action. */
export function canMutate(state: FreshnessState): boolean {
return state === 'current';
}
/** Defense in depth: reject the mutation call itself on non-current data. */
export function assertMutable(state: FreshnessState): void {
if (!canMutate(state)) {
throw new StaleMutationError(state);
}
}
/**
* Combine freshness across a multi-collection surface (primary + secondaries).
* The primary collection gates the surface: unknown while it loads,
* unavailable when it fails. Missing secondaries degrade the surface to
* `partial`; aged collections degrade it to `stale`.
*/
export function combineFreshness(
primary: FreshnessState,
secondaries: readonly FreshnessState[],
): FreshnessState {
if (primary === 'unavailable') return 'unavailable';
if (primary === 'unknown') return 'unknown';
if (secondaries.includes('unavailable')) return 'partial';
if (secondaries.includes('unknown')) return 'unknown';
if (secondaries.includes('stale') || primary === 'stale') return 'stale';
if (secondaries.includes('partial')) return 'partial';
return 'current';
}
/** Render-safe age label for snapshot provenance. */
export function formatAge(fetchedAt: number, now: number): string {
const ageMs = Math.max(0, now - fetchedAt);
if (ageMs < 10_000) return 'just now';
const minutes = Math.floor(ageMs / 60_000);
if (minutes < 1) return 'under a minute ago';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
/** Derived verdict placeholder for non-current inputs — never a green value. */
export const UNKNOWN_VERDICT = '?';
export function verdictValue(verified: boolean, value: string): string {
return verified ? value : UNKNOWN_VERDICT;
}
@@ -1,197 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from './model';
import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache';
import { validateProjectCollection, validateTaskCollection } from './validators';
import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
import type { Project, Task } from '@/lib/types';
const KEY = 'test:tasks';
const NOW = 1_800_000_000_000;
const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 };
function storedTaskSnapshot() {
const result = acceptSnapshot({
value: taskFixtures,
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
return result.snapshot;
}
function storedProjectSnapshot() {
const result = acceptSnapshot({
value: projectFixtures,
validate: validateProjectCollection,
previous: null,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
return result.snapshot;
}
function readTasks() {
return readSnapshotCache({
key: KEY,
workspace: policy.workspace,
policy,
validate: validateTaskCollection,
});
}
/** Write an arbitrary value directly at the raw cache slot. */
function writeRaw(key: string, value: unknown): void {
sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify(value));
}
/** Parse and re-write the stored entry (for tampering with internals). */
function tamperStored<T>(key: string, mutate: (stored: T) => void): void {
const parsed = JSON.parse(sessionStorage.getItem(`mosaic:freshness:v1:${key}`) ?? '{}') as T;
mutate(parsed);
writeRaw(key, parsed);
}
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
sessionStorage.clear();
});
describe('readSnapshotCache', () => {
it('misses when nothing is stored', () => {
expect(readTasks()).toEqual({ outcome: 'miss' });
});
it('hits for a well-formed entry and preserves provenance', () => {
const snapshot = storedTaskSnapshot();
writeSnapshotCache(KEY, snapshot);
const result = readTasks();
expect(result.outcome).toBe('hit');
if (result.outcome === 'hit') {
expect(result.snapshot.data).toEqual(taskFixtures);
expect(result.snapshot.source).toBe('gateway:/api/tasks');
expect(result.snapshot.version).toBe(snapshot.version);
expect(result.snapshot.fetchedAt).toBe(snapshot.fetchedAt);
expect(result.snapshot.workspace).toBe(snapshot.workspace);
}
});
it('invalidates unparsable entries as cache corruption', () => {
sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, '{not json');
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
});
it('invalidates structurally wrong entries as cache corruption', () => {
const malformed: unknown[] = [
'nested but not a snapshot',
{ data: taskFixtures }, // missing provenance fields
{
data: taskFixtures,
source: 1,
workspace: 'w',
version: 1,
schemaVersion: 1,
fetchedAt: 1,
digest: 'x',
},
null,
17,
];
for (const entry of malformed) {
writeRaw(KEY, entry);
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
}
});
it('invalidates digest mismatches as cache corruption (tampered data)', () => {
writeSnapshotCache(KEY, storedTaskSnapshot());
tamperStored<{ data: Task[] }>(KEY, (stored) => {
stored.data = [...stored.data, { ...stored.data[0]!, id: 'injected-task' }];
});
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
});
it('invalidates entries scoped to another workspace', () => {
const snapshot = storedTaskSnapshot();
writeSnapshotCache(KEY, { ...snapshot, workspace: 'someone-else' });
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
});
it('invalidates entries written by a newer schema as a version regression', () => {
const snapshot = storedTaskSnapshot();
writeSnapshotCache(KEY, { ...snapshot, schemaVersion: policy.schemaVersion + 1 });
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
});
it('invalidates entries whose data no longer validates (schema mismatch)', () => {
writeSnapshotCache(KEY, storedTaskSnapshot());
tamperStored<{ data: unknown }>(KEY, (stored) => {
stored.data = { malformed: true };
});
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' });
});
it('never reports a corrupted raw entry as a hit (negative control)', () => {
for (const raw of ['{oops', 'null', '"string"', '[]', '12']) {
sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, raw);
const result = readTasks();
expect(result.outcome).not.toBe('hit');
expect(result.outcome).toBe('invalidated');
}
});
it('scopes project collections by their workspace identity', () => {
const snapshot = storedProjectSnapshot();
writeSnapshotCache('test:projects', snapshot);
const sameScope = readSnapshotCache({
key: 'test:projects',
workspace: 'user-1',
policy,
validate: validateProjectCollection,
});
expect(sameScope.outcome).toBe('hit');
const foreignScope = readSnapshotCache({
key: 'test:projects',
workspace: 'user-2',
policy,
validate: validateProjectCollection,
});
expect(foreignScope).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
});
});
describe('writeSnapshotCache round-trip', () => {
it('round-trips an accepted project snapshot', () => {
const snapshot = storedProjectSnapshot();
writeSnapshotCache('test:projects', snapshot);
const result = readSnapshotCache({
key: 'test:projects',
workspace: snapshot.workspace,
policy,
validate: validateProjectCollection,
});
expect(result.outcome).toBe('hit');
if (result.outcome === 'hit') {
expect(result.snapshot.data).toEqual(projectFixtures as Project[]);
}
});
});
describe('clearSnapshotCache', () => {
it('drops the entry so the next read misses', () => {
writeSnapshotCache(KEY, storedTaskSnapshot());
expect(readTasks().outcome).toBe('hit');
clearSnapshotCache(KEY);
expect(readTasks()).toEqual({ outcome: 'miss' });
});
});
@@ -1,154 +0,0 @@
import {
computeDigest,
type FreshPayload,
type FreshSnapshot,
type FreshnessPolicy,
type InvalidationReason,
} from './model';
/**
* Session-scoped last-known snapshot cache (RI-5-001).
*
* Restored snapshots are situational awareness only: they surface as `stale`
* until a fetch re-verifies them. A cache entry that is corrupted, belongs to
* another workspace, was written by a newer schema, or no longer validates is
* invalidated (treated as unavailable, never rendered as current).
*/
const CACHE_PREFIX = 'mosaic:freshness:v1';
interface StoredSnapshot {
data: unknown;
source: string;
workspace: string;
version: number;
schemaVersion: number;
fetchedAt: number;
digest: string;
}
export type SnapshotCacheRead<T> =
| { readonly outcome: 'hit'; readonly snapshot: FreshSnapshot<T> }
| { readonly outcome: 'miss' }
| { readonly outcome: 'invalidated'; readonly reason: InvalidationReason };
export interface ReadSnapshotCacheOptions<T> {
readonly key: string;
readonly workspace: string;
readonly policy: FreshnessPolicy;
readonly validate: (value: unknown) => FreshPayload<T> | null;
}
function cacheKey(key: string): string {
return `${CACHE_PREFIX}:${key}`;
}
function isStoredSnapshot(value: unknown): value is StoredSnapshot {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate['data'] === 'object' &&
candidate['data'] !== null &&
typeof candidate['source'] === 'string' &&
typeof candidate['workspace'] === 'string' &&
typeof candidate['version'] === 'number' &&
typeof candidate['schemaVersion'] === 'number' &&
typeof candidate['fetchedAt'] === 'number' &&
typeof candidate['digest'] === 'string'
);
}
function getStorage(): Storage | null {
try {
return globalThis.sessionStorage ?? null;
} catch {
return null;
}
}
/**
* Restore a cached snapshot under the active workspace scope. Every failure
* mode maps to an explicit invalidation reason or a miss — never to data
* that renders as current.
*/
export function readSnapshotCache<T>(options: ReadSnapshotCacheOptions<T>): SnapshotCacheRead<T> {
const storage = getStorage();
if (storage === null) return { outcome: 'miss' };
let raw: string | null;
try {
raw = storage.getItem(cacheKey(options.key));
} catch {
return { outcome: 'miss' };
}
if (raw === null) return { outcome: 'miss' };
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { outcome: 'invalidated', reason: 'cache-corruption' };
}
if (!isStoredSnapshot(parsed)) {
return { outcome: 'invalidated', reason: 'cache-corruption' };
}
if (parsed.workspace !== options.workspace) {
return { outcome: 'invalidated', reason: 'cross-workspace' };
}
if (parsed.schemaVersion > options.policy.schemaVersion) {
// Written by a newer build than the running client: version regression.
return { outcome: 'invalidated', reason: 'version-regression' };
}
const payload = options.validate(parsed.data);
if (payload === null) {
return { outcome: 'invalidated', reason: 'schema-mismatch' };
}
if (computeDigest(payload.data) !== parsed.digest) {
return { outcome: 'invalidated', reason: 'cache-corruption' };
}
return {
outcome: 'hit',
snapshot: {
data: payload.data,
source: parsed.source,
workspace: parsed.workspace,
version: parsed.version,
schemaVersion: parsed.schemaVersion,
fetchedAt: parsed.fetchedAt,
digest: parsed.digest,
},
};
}
/** Persist a verified snapshot. Failures are non-fatal (cache is best-effort). */
export function writeSnapshotCache<T>(key: string, snapshot: FreshSnapshot<T>): void {
const storage = getStorage();
if (storage === null) return;
const stored: StoredSnapshot = {
data: snapshot.data,
source: snapshot.source,
workspace: snapshot.workspace,
version: snapshot.version,
schemaVersion: snapshot.schemaVersion,
fetchedAt: snapshot.fetchedAt,
digest: snapshot.digest,
};
try {
storage.setItem(cacheKey(key), JSON.stringify(stored));
} catch {
// Quota or serialization failures simply skip caching.
}
}
/** Drop a cached snapshot (used when a surface invalidates its cache entry). */
export function clearSnapshotCache(key: string): void {
const storage = getStorage();
if (storage === null) return;
try {
storage.removeItem(cacheKey(key));
} catch {
// Ignorable: a wedged storage entry is detected as corruption on read.
}
}
@@ -1,372 +0,0 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Task } from '@/lib/types';
import { acceptSnapshot, StaleMutationError, DEFAULT_FRESHNESS_POLICY } from './model';
import type { FreshnessFailure } from './use-fresh-collection';
import {
describeFailure,
useFreshCollection,
type FreshCollection,
type UseFreshCollectionOptions,
} from './use-fresh-collection';
import { validateProjectCollection, validateTaskCollection } from './validators';
import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
/**
* Failure-matrix coverage for the freshness seam (RI-5-001): network failure,
* auth failure, malformed response, cache corruption, stale age, schema
* mismatch, cross-workspace, recovery, and stale-action rejection — with
* negative controls proving no case yields current data or an enabled
* mutation.
*/
const NOW = 1_800_000_000_000;
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
let root: Root | null = null;
let container: HTMLDivElement;
let latest: FreshCollection<Task[]> | null = null;
function Probe({
options,
}: {
options: UseFreshCollectionOptions<Task[]>;
}): React.ReactElement | null {
latest = useFreshCollection<Task[]>(options);
return null;
}
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
beforeEach(() => {
sessionStorage.clear();
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
latest = null;
sessionStorage.clear();
vi.restoreAllMocks();
});
async function renderCollection(
options: UseFreshCollectionOptions<Task[]>,
): Promise<FreshCollection<Task[]>> {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<Probe options={options} />);
});
if (latest === null) throw new Error('hook did not run');
return latest;
}
function taskOptions(
overrides: Partial<UseFreshCollectionOptions<Task[]>> = {},
): UseFreshCollectionOptions<Task[]> {
return {
source: 'gateway:/api/tasks',
fetcher: () => Promise.resolve(taskFixtures),
validate: validateTaskCollection,
cacheKey: 'tasks',
clock: () => NOW,
...overrides,
};
}
function authError(statusCode: number): Error & { statusCode: number } {
return Object.assign(new Error(`Request failed with ${statusCode}`), { statusCode });
}
function seedCache(key: string): number {
const result = acceptSnapshot({
value: taskFixtures,
validate: validateTaskCollection,
previous: null,
policy: DEFAULT_FRESHNESS_POLICY,
source: 'gateway:/api/tasks',
now: NOW,
});
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify({ ...result.snapshot }));
return result.snapshot.version;
}
describe('useFreshCollection failure matrix', () => {
it('is unknown (not empty) while the first validation is in flight', async () => {
const deferred = createDeferred<Task[]>();
const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
expect(collection.freshness).toBe('unknown');
expect(collection.validating).toBe(true);
expect(collection.data).toBeNull();
expect(collection.canMutate).toBe(false);
await act(async () => {
deferred.resolve(taskFixtures);
await deferred.promise;
});
});
it('becomes current with provenance after a verified fetch', async () => {
const collection = await renderCollection(taskOptions());
expect(collection.freshness).toBe('current');
expect(collection.data).toEqual(taskFixtures);
expect(collection.snapshot?.source).toBe('gateway:/api/tasks');
expect(collection.snapshot?.version).toBe(1);
expect(collection.failure).toBeNull();
expect(collection.canMutate).toBe(true);
// Verified snapshot is persisted for last-known restore.
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeTruthy();
});
it('treats a network failure as unavailable — never an empty healthy collection', async () => {
const collection = await renderCollection(
taskOptions({ fetcher: () => Promise.reject(new Error('network down')) }),
);
expect(collection.freshness).toBe('unavailable');
expect(collection.data).toBeNull();
expect(collection.failure).toEqual({ kind: 'fetch', message: 'network down' });
expect(collection.canMutate).toBe(false);
expect(describeFailure(collection.failure)).toBe('network down');
});
it('treats an auth failure as unavailable and drops the last-known snapshot', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1 ? Promise.resolve(taskFixtures) : Promise.reject(authError(401));
},
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('unavailable');
expect(latest?.data).toBeNull();
expect(latest?.failure?.kind).toBe('fetch');
// The previous user's data must not linger in the session cache.
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull();
});
it('invalidates a malformed response as a schema mismatch', async () => {
const collection = await renderCollection(
taskOptions({ fetcher: () => Promise.resolve({ malformed: true }) }),
);
expect(collection.freshness).toBe('unavailable');
expect(collection.data).toBeNull();
expect(collection.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' });
expect(collection.canMutate).toBe(false);
});
it('keeps the previous snapshot as labeled stale when a later payload mismatches', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1 ? Promise.resolve(taskFixtures) : Promise.resolve('garbage');
},
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('stale');
expect(latest?.data).toEqual(taskFixtures);
expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' });
expect(latest?.canMutate).toBe(false);
});
it('drops the snapshot when the workspace changes under it (cross-workspace)', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return Promise.resolve(
call === 1 ? projectFixtures : [{ ...projectFixtures[0], userId: 'user-2' }],
);
},
validate: validateProjectCollection as unknown as (value: unknown) => {
data: Task[];
workspace: string | null;
},
source: 'gateway:/api/projects',
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('unavailable');
expect(latest?.data).toBeNull();
expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'cross-workspace' });
});
it('ages from current to stale and refuses mutations on stale data', async () => {
let fakeNow = NOW;
const collection = await renderCollection(
taskOptions({
clock: () => fakeNow,
policy: { staleAfterMs: 40 },
tickMs: 10,
}),
);
expect(collection.freshness).toBe('current');
// Age the snapshot past the policy and let the tick recompute.
fakeNow = NOW + 60;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 25));
});
expect(latest?.freshness).toBe('stale');
expect(latest?.data).toEqual(taskFixtures);
expect(latest?.canMutate).toBe(false);
const operation = vi.fn(async () => 'result');
await expect(latest?.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
expect(operation).not.toHaveBeenCalled();
});
it('recovers to current after a successful revalidation', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1
? Promise.reject(new Error('first attempt failed'))
: Promise.resolve(taskFixtures);
},
}),
);
expect(collection.freshness).toBe('unavailable');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('current');
expect(latest?.failure).toBeNull();
const operation = vi.fn(async (data: Task[]) => data.length);
await expect(latest?.mutate(operation)).resolves.toBe(taskFixtures.length);
expect(operation).toHaveBeenCalledOnce();
});
it('restores a cached snapshot as unverified stale data, then verifies it', async () => {
const seededVersion = seedCache('tasks');
const deferred = createDeferred<Task[]>();
const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
// Restored data is situational awareness only: labeled stale, never
// current, and mutations are refused before verification.
expect(collection.freshness).toBe('stale');
expect(collection.data).toEqual(taskFixtures);
expect(collection.canMutate).toBe(false);
await expect(collection.mutate(vi.fn())).rejects.toBeInstanceOf(StaleMutationError);
await act(async () => {
deferred.resolve(taskFixtures);
await deferred.promise;
});
expect(latest?.freshness).toBe('current');
expect(latest?.snapshot?.version).toBe(seededVersion + 1);
});
it('never promotes corrupted cache data to current (cache corruption)', async () => {
sessionStorage.setItem('mosaic:freshness:v1:tasks', '{"data":');
const collection = await renderCollection(
taskOptions({ fetcher: () => Promise.reject(new Error('still down')) }),
);
expect(collection.freshness).toBe('unavailable');
expect(collection.data).toBeNull();
expect(collection.canMutate).toBe(false);
// The corrupted entry is dropped so it cannot come back.
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull();
});
it('refuses mutations while unknown or unavailable — the call itself, not just the button', async () => {
const deferred = createDeferred<Task[]>();
const unknown = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
const operation = vi.fn(async () => 'result');
await expect(unknown.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
expect(operation).not.toHaveBeenCalled();
await act(async () => {
deferred.reject(new Error('failed'));
await deferred.promise.catch(() => undefined);
});
const unavailable = latest!;
await expect(unavailable.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
expect(operation).not.toHaveBeenCalled();
expect(unavailable.canMutate).toBe(false);
});
it('degrades to stale with last-known data when a revalidation fails after success', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1
? Promise.resolve(taskFixtures)
: Promise.reject(new Error('connection lost'));
},
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('stale');
expect(latest?.data).toEqual(taskFixtures);
const failure: FreshnessFailure | null = latest?.failure ?? null;
expect(failure).toEqual({ kind: 'fetch', message: 'connection lost' });
});
});
@@ -1,281 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
acceptSnapshot,
assertMutable,
computeFreshness,
DEFAULT_FRESHNESS_POLICY,
invalidationReasonLabels,
type FreshPayload,
type FreshSnapshot,
type FreshnessPolicy,
type FreshnessState,
type InvalidationReason,
StaleMutationError,
} from './model';
import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache';
/**
* Freshness-aware collection fetch hook (RI-5-001).
*
* One hook owns one gateway collection end to end: fetch, schema validation,
* snapshot acceptance with provenance, session-scoped last-known caching,
* aging, and the mutation guard. Pages consume `freshness` and never infer
* health from emptiness.
*/
/** Why the latest validation did not produce a current snapshot. */
export type FreshnessFailure =
| { readonly kind: 'fetch'; readonly message: string }
| { readonly kind: 'invalidated'; readonly reason: InvalidationReason };
export interface UseFreshCollectionOptions<T> {
/** Source identity for provenance labels, e.g. `gateway:/api/tasks`. */
readonly source: string;
/** Performs the unvalidated fetch. The hook owns abort and verification. */
readonly fetcher: (signal: AbortSignal) => Promise<unknown>;
/**
* Runtime schema validator. Returning `null` invalidates the payload
* (`schema-mismatch`) instead of letting malformed JSON flow into render.
*/
readonly validate: (value: unknown) => FreshPayload<T> | null;
/** Overrides of the default freshness policy. */
readonly policy?: Partial<FreshnessPolicy>;
/**
* Session cache key for last-known snapshots. `null`/omitted disables
* restore. Restored snapshots are unverified: they render only as
* labeled `stale` data until a fetch re-verifies them.
*/
readonly cacheKey?: string | null;
/** Injectable clock for deterministic age transitions in tests. */
readonly clock?: () => number;
/** Aging tick interval override (default derived from `staleAfterMs`). */
readonly tickMs?: number;
/** When false, no fetch runs (surfaces stay `unavailable`/`unknown`). */
readonly enabled?: boolean;
}
export interface FreshCollection<T> {
/** Last verified (or restored-unverified) snapshot, or `null`. */
readonly snapshot: FreshSnapshot<T> | null;
/** Snapshot data or `null` — never a fabricated empty collection. */
readonly data: T | null;
readonly freshness: FreshnessState;
/** True while a validation request is in flight. */
readonly validating: boolean;
/** Outcome of the latest failed validation, `null` when healthy. */
readonly failure: FreshnessFailure | null;
/** False unless freshness is `current`; drives disabled UI affordances. */
readonly canMutate: boolean;
/** Re-run the fetch and re-verify. Always allowed (it is a read). */
readonly revalidate: () => Promise<void>;
/**
* Run a state-changing operation against verified-current data only.
* Rejects with `StaleMutationError` on any other state — the guard fires
* even if a disabled button was bypassed (defense in depth).
*/
readonly mutate: <R>(operation: (data: T) => Promise<R>) => Promise<R>;
}
const defaultClock = (): number => Date.now();
function resolveTickMs(policy: FreshnessPolicy, override?: number): number {
if (override !== undefined && override > 0) return override;
return Math.min(5_000, Math.max(250, Math.floor(policy.staleAfterMs / 4)));
}
function isAuthFailure(caught: unknown): boolean {
return (
typeof caught === 'object' &&
caught !== null &&
'statusCode' in caught &&
((caught as { statusCode?: unknown }).statusCode === 401 ||
(caught as { statusCode?: unknown }).statusCode === 403)
);
}
function fetchFailureMessage(caught: unknown): string {
if (caught instanceof Error && caught.message.trim().length > 0) return caught.message;
return 'The request failed.';
}
/** Human-readable summary of a failure for unavailable/stale notices. */
export function describeFailure(failure: FreshnessFailure | null): string | null {
if (failure === null) return null;
if (failure.kind === 'fetch') return failure.message;
return `The snapshot was invalidated: ${invalidationReasonLabels[failure.reason]}.`;
}
export function useFreshCollection<T>(options: UseFreshCollectionOptions<T>): FreshCollection<T> {
const optionsRef = useRef(options);
optionsRef.current = options;
const policy = useMemo<FreshnessPolicy>(
() => ({ ...DEFAULT_FRESHNESS_POLICY, ...options.policy }),
[options.policy],
);
const policyRef = useRef(policy);
policyRef.current = policy;
const clockRef = useRef(options.clock ?? defaultClock);
clockRef.current = options.clock ?? defaultClock;
const [snapshot, setSnapshot] = useState<FreshSnapshot<T> | null>(null);
const [failure, setFailure] = useState<FreshnessFailure | null>(null);
const [unverified, setUnverified] = useState(false);
const [validating, setValidating] = useState(options.enabled !== false);
const [now, setNow] = useState(() => (options.clock ?? defaultClock)());
const snapshotRef = useRef(snapshot);
snapshotRef.current = snapshot;
const failureRef = useRef(failure);
failureRef.current = failure;
const unverifiedRef = useRef(unverified);
unverifiedRef.current = unverified;
const runRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
const revalidate = useCallback(async (): Promise<void> => {
const current = optionsRef.current;
if (current.enabled === false) {
setValidating(false);
return;
}
const runId = ++runRef.current;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setValidating(true);
let value: unknown;
try {
value = await current.fetcher(controller.signal);
} catch (caught) {
if (runRef.current !== runId || controller.signal.aborted) return;
if (isAuthFailure(caught)) {
// An unauthenticated viewer must not keep (or be served) the
// previous user's last-known data.
setSnapshot(null);
setUnverified(false);
if (current.cacheKey) clearSnapshotCache(current.cacheKey);
}
setFailure({ kind: 'fetch', message: fetchFailureMessage(caught) });
setValidating(false);
return;
}
if (runRef.current !== runId) return;
const result = acceptSnapshot({
value,
validate: current.validate,
previous: snapshotRef.current,
policy: policyRef.current,
source: current.source,
now: clockRef.current(),
});
if (result.outcome === 'accepted') {
setSnapshot(result.snapshot);
setUnverified(false);
setFailure(null);
if (current.cacheKey) writeSnapshotCache(current.cacheKey, result.snapshot);
} else {
if (result.reason === 'cross-workspace') {
// Data verified for a different workspace must not linger as
// last-known situational awareness either.
setSnapshot(null);
setUnverified(false);
}
if (current.cacheKey) clearSnapshotCache(current.cacheKey);
setFailure({ kind: 'invalidated', reason: result.reason });
}
setValidating(false);
}, []);
// Restore the last-known snapshot (unverified) and run the first fetch.
useEffect(() => {
if (optionsRef.current.enabled === false) {
setValidating(false);
return;
}
const cacheKey = optionsRef.current.cacheKey;
if (cacheKey) {
const restored = readSnapshotCache<T>({
key: cacheKey,
workspace: policyRef.current.workspace,
policy: policyRef.current,
validate: optionsRef.current.validate,
});
if (restored.outcome === 'hit') {
setSnapshot(restored.snapshot);
setUnverified(true);
} else if (restored.outcome === 'invalidated') {
// A corrupted/foreign/regressed entry is dropped immediately; it must
// never surface as data. The fetch decides the visible state.
clearSnapshotCache(cacheKey);
}
}
void revalidate();
return () => {
abortRef.current?.abort();
};
// Mount-once by design: `revalidate` is stable and reads live options
// through refs, so it never needs to re-run when options change.
// Route-param pages remount this hook via an identity `key` instead.
}, [revalidate]);
// Aging tick: recomputes freshness as the snapshot ages past the policy.
useEffect(() => {
const interval = setInterval(
() => {
setNow(clockRef.current());
},
resolveTickMs(policyRef.current, optionsRef.current.tickMs),
);
return () => clearInterval(interval);
}, []);
const freshness = useMemo<FreshnessState>(() => {
if (snapshot === null) return validating ? 'unknown' : 'unavailable';
return computeFreshness({
snapshot,
policy,
now,
degraded: failure !== null || unverified,
});
// `now` from state covers age; refs inside computeFreshness are pure.
}, [snapshot, validating, failure, unverified, now, policy]);
const canMutate = freshness === 'current';
const mutate = useCallback(async <R>(operation: (data: T) => Promise<R>): Promise<R> => {
const currentSnapshot = snapshotRef.current;
// No verified snapshot at all: with nothing verified there is nothing
// current to mutate, regardless of the recorded failure.
if (currentSnapshot === null) throw new StaleMutationError('unavailable');
const state = computeFreshness({
snapshot: currentSnapshot,
policy: policyRef.current,
now: clockRef.current(),
degraded: failureRef.current !== null || unverifiedRef.current,
});
assertMutable(state);
return operation(currentSnapshot.data);
}, []);
return {
snapshot,
data: snapshot === null ? null : snapshot.data,
freshness,
validating,
failure,
canMutate,
revalidate,
mutate,
};
}
@@ -1,103 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { Mission, Project, Task } from '@/lib/types';
import {
validateMissionCollection,
validateProjectCollection,
validateProjectEntity,
validateTaskCollection,
} from './validators';
import { missionFixtures, projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
describe('validateTaskCollection', () => {
it('accepts a well-formed task collection', () => {
expect(validateTaskCollection(taskFixtures)).toEqual({
data: taskFixtures,
workspace: null,
});
});
it('accepts an empty collection (a healthy empty state is a valid payload)', () => {
expect(validateTaskCollection([])).toEqual({ data: [], workspace: null });
});
it.each([
['not an array', { items: [] }],
['item is not an object', ['nope']],
['missing id', [{ ...(taskFixtures[0] as Task), id: undefined }]],
['missing title', [{ ...(taskFixtures[0] as Task), title: undefined }]],
['unknown status enum', [{ ...(taskFixtures[0] as Task), status: 'finished' }]],
['unknown priority enum', [{ ...(taskFixtures[0] as Task), priority: 'urgent' }]],
['tags of the wrong type', [{ ...(taskFixtures[0] as Task), tags: 'spa' }]],
['metadata of the wrong type', [{ ...(taskFixtures[0] as Task), metadata: 'notes' }]],
['createdAt of the wrong type', [{ ...(taskFixtures[0] as Task), createdAt: 1234 }]],
['null sneaks past a required string', [{ ...(taskFixtures[0] as Task), title: null }]],
])('rejects a malformed payload: %s', (_label, value) => {
expect(validateTaskCollection(value)).toBeNull();
});
});
describe('validateMissionCollection', () => {
it('accepts a well-formed mission collection', () => {
expect(validateMissionCollection(missionFixtures)).toEqual({
data: missionFixtures,
workspace: null,
});
});
it.each([
['not an array', null],
['item missing name', [{ ...(missionFixtures[0] as Mission), name: 42 }]],
['unknown status enum', [{ ...(missionFixtures[0] as Mission), status: 'canceled' }]],
['projectId of the wrong type', [{ ...(missionFixtures[0] as Mission), projectId: 7 }]],
])('rejects a malformed payload: %s', (_label, value) => {
expect(validateMissionCollection(value)).toBeNull();
});
});
describe('validateProjectCollection', () => {
it('accepts a uniform workspace-scoped collection and reports its workspace', () => {
expect(validateProjectCollection(projectFixtures)).toEqual({
data: projectFixtures,
workspace: 'user-1',
});
});
it('accepts an empty collection with no workspace identity', () => {
expect(validateProjectCollection([])).toEqual({ data: [], workspace: null });
});
it.each([
['not an array', 42],
['item missing userId', [{ ...(projectFixtures[0] as Project), userId: undefined }]],
['unknown status enum', [{ ...(projectFixtures[0] as Project), status: 'live' }]],
['description of the wrong type', [{ ...(projectFixtures[0] as Project), description: 1 }]],
])('rejects a malformed payload: %s', (_label, value) => {
expect(validateProjectCollection(value)).toBeNull();
});
it('rejects a collection mixing workspace identities (cross-workspace leak)', () => {
const mixed = [
projectFixtures[0] as Project,
{ ...(projectFixtures[1] as Project), userId: 'user-2' },
];
expect(validateProjectCollection(mixed)).toBeNull();
});
});
describe('validateProjectEntity', () => {
it('accepts a well-formed project and reports its workspace', () => {
expect(validateProjectEntity(projectFixtures[0])).toEqual({
data: projectFixtures[0],
workspace: 'user-1',
});
});
it.each([
['not an object', 'project-1'],
['null', null],
['array', [projectFixtures[0]]],
['missing userId', [{ ...(projectFixtures[0] as Project), userId: null }]],
])('rejects a malformed entity: %s', (_label, value) => {
expect(validateProjectEntity(value)).toBeNull();
});
});
-135
View File
@@ -1,135 +0,0 @@
import type { Mission, Project, Task, MissionStatus, TaskPriority, TaskStatus } from '@/lib/types';
import type { FreshPayload } from './model';
/**
* Runtime schema validators for gateway collections (RI-5-001).
*
* `api<T>()` returns untrusted JSON cast to `T`; these validators are the
* seam where a malformed response becomes an explicit schema mismatch
* instead of flowing into the render path as if it were healthy data.
*/
const taskStatuses: readonly TaskStatus[] = [
'not-started',
'in-progress',
'blocked',
'done',
'cancelled',
];
const taskPriorities: readonly TaskPriority[] = ['critical', 'high', 'medium', 'low'];
const missionStatuses: readonly MissionStatus[] = [
'planning',
'active',
'paused',
'completed',
'failed',
];
const projectStatuses: readonly Project['status'][] = ['active', 'paused', 'completed', 'archived'];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}
function isOneOf<T extends string>(value: unknown, allowed: readonly T[]): value is T {
return typeof value === 'string' && (allowed as readonly string[]).includes(value);
}
function isNullableRecord(value: unknown): value is Record<string, unknown> | null {
return value === null || isRecord(value);
}
function isNullableStringArray(value: unknown): value is string[] | null {
if (value === null) return true;
if (!Array.isArray(value)) return false;
return value.every((item) => typeof item === 'string');
}
function isIsoLike(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
function isTask(value: unknown): value is Task {
if (!isRecord(value)) return false;
return (
isString(value['id']) &&
isString(value['title']) &&
isOneOf(value['status'], taskStatuses) &&
isOneOf(value['priority'], taskPriorities) &&
isNullableString(value['projectId']) &&
isNullableString(value['missionId']) &&
isNullableString(value['assignee']) &&
isNullableStringArray(value['tags']) &&
isNullableRecord(value['metadata']) &&
isNullableString(value['dueDate']) &&
isIsoLike(value['createdAt']) &&
isIsoLike(value['updatedAt'])
);
}
/** Tasks carry no workspace identity; scope falls back to the policy. */
export function validateTaskCollection(value: unknown): FreshPayload<Task[]> | null {
if (!Array.isArray(value) || !value.every(isTask)) return null;
return { data: value as Task[], workspace: null };
}
function isMission(value: unknown): value is Mission {
if (!isRecord(value)) return false;
return (
isString(value['id']) &&
isString(value['name']) &&
isOneOf(value['status'], missionStatuses) &&
isNullableString(value['projectId']) &&
isNullableString(value['description']) &&
isNullableRecord(value['metadata']) &&
isIsoLike(value['createdAt']) &&
isIsoLike(value['updatedAt'])
);
}
/** Missions carry no workspace identity; scope falls back to the policy. */
export function validateMissionCollection(value: unknown): FreshPayload<Mission[]> | null {
if (!Array.isArray(value) || !value.every(isMission)) return null;
return { data: value as Mission[], workspace: null };
}
function isProject(value: unknown): value is Project {
if (!isRecord(value)) return false;
return (
isString(value['id']) &&
isString(value['name']) &&
isOneOf(value['status'], projectStatuses) &&
isString(value['userId']) &&
isNullableString(value['description']) &&
isNullableRecord(value['metadata']) &&
isIsoLike(value['createdAt']) &&
isIsoLike(value['updatedAt'])
);
}
/**
* Projects are workspace-scoped: every item must carry the same `userId`.
* A collection mixing identities (cross-workspace leak) is a schema
* mismatch; the uniform `userId` becomes the snapshot workspace.
*/
export function validateProjectCollection(value: unknown): FreshPayload<Project[]> | null {
if (!Array.isArray(value) || !value.every(isProject)) return null;
const projects = value as Project[];
const workspaces = new Set(projects.map((project) => project.userId));
if (workspaces.size > 1) return null;
return { data: projects, workspace: projects.length > 0 ? projects[0]!.userId : null };
}
/** Single project entity (project detail primary collection). */
export function validateProjectEntity(value: unknown): FreshPayload<Project> | null {
if (!isProject(value)) return null;
const project = value as Project;
return { data: project, workspace: project.userId };
}
+19 -163
View File
@@ -35,7 +35,6 @@ afterEach(async () => {
document.body.replaceChildren();
root = null;
apiMock.mockReset();
sessionStorage.clear();
});
async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> {
@@ -65,49 +64,21 @@ function clickButtonByText(text: string): void {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
async function flushAct(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
const projectOneTasks = taskFixtures.filter((task) => task.projectId === 'project-1');
function mockHealthyLoad(): void {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(projectOneTasks);
}
describe('ProjectDetailPage', () => {
it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
mockHealthyLoad();
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
await renderProjectDetailPage();
expect(apiMock.mock.calls.map((call) => call[0])).toEqual([
'/api/projects/project-1',
'/api/missions',
'/api/tasks?projectId=project-1',
expect(apiMock.mock.calls).toEqual([
['/api/projects/project-1'],
['/api/missions'],
['/api/tasks?projectId=project-1'],
]);
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
expect(container.textContent).toContain('Mosaic Stack');
expect(container.textContent).toContain('Route /projects/:id');
expect(container.textContent).toContain('Tasks');
@@ -130,7 +101,10 @@ describe('ProjectDetailPage', () => {
});
it('opens and closes the existing read-only task modal from the tasks tab', async () => {
mockHealthyLoad();
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
await renderProjectDetailPage();
@@ -160,153 +134,35 @@ describe('ProjectDetailPage', () => {
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it('shows verified completion verdicts when the task collection is current', async () => {
mockHealthyLoad();
await renderProjectDetailPage();
const doneCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === 'Done1',
);
expect(doneCard).toBeTruthy();
const inProgressCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === 'In Progress1',
);
expect(inProgressCard).toBeTruthy();
});
it('renders an explicit unavailable missions tab when the missions request fails (partial, not empty)', async () => {
it('renders the project with an empty missions tab when the missions request fails', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockRejectedValueOnce(new Error('Missions request failed'))
.mockResolvedValueOnce(projectOneTasks);
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
await renderProjectDetailPage();
// Secondary failure degrades the surface to partial; the project itself
// still renders.
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
expect(container.textContent).toContain('Mosaic Stack');
const partial = container.querySelector('[role="status"]');
expect(partial?.textContent).toContain('Missions');
expect(partial?.textContent).toContain('unavailable');
expect(container.querySelector('[role="alert"]')).toBeNull();
await act(async () => {
clickButtonByText('Missions (?)');
clickButtonByText('Missions (0)');
});
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Missions request failed');
// Negative control: a failed fetch must not look like an empty list.
expect(container.textContent).not.toContain('No missions for this project');
expect(container.textContent).toContain('No missions for this project');
});
it('marks derived verdicts unknown when the tasks collection is unavailable', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockRejectedValueOnce(new Error('Tasks request failed'));
await renderProjectDetailPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
// Completion verdicts become unknown ('?') — never green counts.
for (const label of ['Done', 'In Progress', 'Blocked', 'Tasks']) {
const unknownCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === `${label}?`,
);
expect(unknownCard, `expected ${label} card to render ?`).toBeTruthy();
}
// Negative control: no green "Done 1" verdict anywhere.
expect(
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
).toBe(false);
await act(async () => {
clickButtonByText('Tasks (?)');
});
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Tasks request failed');
// Negative control: no healthy empty task list from a failed fetch.
expect(container.textContent).not.toContain('No tasks found');
expect(container.querySelector('table')).toBeNull();
});
it('recovers a partial surface to current after revalidation', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockRejectedValueOnce(new Error('Tasks request failed'))
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(projectOneTasks);
await renderProjectDetailPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
await act(async () => {
clickButtonByText('Revalidate');
});
await flushAct();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
expect(
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
).toBe(true);
});
it("never shows one project's data on another project's route after navigation", async () => {
mockHealthyLoad();
const router = await renderProjectDetailPage();
expect(container.textContent).toContain('Mosaic Stack');
const deferred = createDeferred<(typeof projectFixtures)[number]>();
apiMock
.mockResolvedValueOnce(deferred.promise)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await act(async () => {
await router.navigate('/projects/project-2');
});
// While project-2 loads, nothing from project-1 may render on its route.
expect(container.textContent).toContain('Loading project...');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.textContent).not.toContain('Route /projects/:id');
await act(async () => {
deferred.resolve(projectFixtures[1]!);
await deferred.promise;
});
expect(container.textContent).toContain('Agent Runtime');
expect(apiMock.mock.calls[3]?.[0]).toBe('/api/projects/project-2');
});
it('renders a visible unavailable state when the project request fails and lets the user navigate back', async () => {
it('renders a visible alert when the project request fails and lets the user navigate back', async () => {
apiMock
.mockRejectedValueOnce(new Error('Project request failed'))
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(projectOneTasks);
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
const router = await renderProjectDetailPage();
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Project request failed');
expect(alert?.textContent).toContain('not an empty result');
expect(container.textContent).not.toContain('Mosaic Stack');
await act(async () => {
+81 -194
View File
@@ -1,30 +1,14 @@
import { useState, type ReactElement } from 'react';
import { useEffect, useState, type ReactElement } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { MissionTimeline } from '@/components/projects/mission-timeline';
import { PrdViewer } from '@/components/projects/prd-viewer';
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
import { TaskListView } from '@/components/tasks/task-list-view';
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
import {
PartialDataNotice,
StaleDataNotice,
UnavailableDataNotice,
} from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
import {
combineFreshness,
UNKNOWN_VERDICT,
verdictValue,
type FreshSnapshot,
} from '@/lib/freshness/model';
import { describeFailure, useFreshCollection } from '@/lib/freshness/use-fresh-collection';
import {
validateMissionCollection,
validateProjectEntity,
validateTaskCollection,
} from '@/lib/freshness/validators';
import { getErrorMessage } from './page-errors';
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
@@ -67,75 +51,55 @@ function TabButton({ id, label, activeTab, onClick }: TabButtonProps): ReactElem
);
}
/** Remounts per project id so no state from one project renders for another. */
export function ProjectDetailPage(): ReactElement {
const { id = '' } = useParams();
return <ProjectDetail id={id} key={id} />;
}
function ProjectDetail({ id }: { id: string }): ReactElement {
const navigate = useNavigate();
const enabled = id.length > 0;
// Primary collection gates the surface; missions and tasks are secondaries
// whose failures degrade the surface to `partial` instead of rendering
// empty healthy lists.
const project = useFreshCollection<Project>({
source: `gateway:/api/projects/${id}`,
fetcher: (signal) => api<unknown>(`/api/projects/${id}`, { signal }),
validate: validateProjectEntity,
// No last-known restore: the entity carries workspace identity that
// cannot be scope-checked before display (see ProjectsPage note).
enabled,
});
const missions = useFreshCollection<Mission[]>({
source: 'gateway:/api/missions',
fetcher: (signal) => api<unknown>('/api/missions', { signal }),
validate: validateMissionCollection,
cacheKey: enabled ? 'missions' : null,
enabled,
});
const tasks = useFreshCollection<Task[]>({
source: `gateway:/api/tasks?projectId=${id}`,
fetcher: (signal) => api<unknown>(`/api/tasks?projectId=${id}`, { signal }),
validate: validateTaskCollection,
cacheKey: enabled ? `project-tasks:${id}` : null,
enabled,
});
const [project, setProject] = useState<Project | null>(null);
const [missions, setMissions] = useState<Mission[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const surface = combineFreshness(project.freshness, [missions.freshness, tasks.freshness]);
const tasksVerified = tasks.freshness === 'current';
const projectMissions = missions.data?.filter((mission) => mission.projectId === id) ?? null;
useEffect(() => {
if (!id) {
setError('Project id is missing.');
setLoading(false);
return;
}
const retryAll = (): void => {
void Promise.all([project.revalidate(), missions.revalidate(), tasks.revalidate()]);
};
let cancelled = false;
setLoading(true);
setError(null);
if (!enabled) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
Project id is missing.
</div>
<button
type="button"
onClick={() => navigate('/projects')}
className="mt-4 w-fit text-sm underline"
>
Back to projects
</button>
</div>
);
}
void Promise.all([
api<Project>('/api/projects/' + id),
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
api<Task[]>('/api/tasks?projectId=' + id).catch(() => [] as Task[]),
])
.then(([loadedProject, allMissions, loadedTasks]) => {
if (cancelled) return;
setProject(loadedProject);
setMissions(allMissions.filter((mission) => mission.projectId === id));
setTasks(loadedTasks);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load project.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
if (project.freshness === 'unknown') {
return () => {
cancelled = true;
};
}, [id]);
if (loading) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
@@ -146,17 +110,15 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
);
}
if (project.freshness === 'unavailable' || project.data === null) {
if (error || !project) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<UnavailableDataNotice
title="This project"
detail={describeFailure(project.failure)}
onRetry={retryAll}
/>
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
{error ?? 'Project not found.'}
</div>
<button
type="button"
onClick={() => navigate('/projects')}
@@ -168,48 +130,18 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
);
}
const projectTasks = tasks.data ?? null;
const filteredTasks =
projectTasks === null
? []
: taskFilter === 'all'
? projectTasks
: projectTasks.filter((task) => task.status === taskFilter);
// Derived completion verdicts: unknown (never green) unless the task
// collection is verified current.
const doneCount = projectTasks?.filter((task) => task.status === 'done').length ?? 0;
const inProgressCount = projectTasks?.filter((task) => task.status === 'in-progress').length ?? 0;
const blockedCount = projectTasks?.filter((task) => task.status === 'blocked').length ?? 0;
const prdContent = getPrdContent(project.data);
taskFilter === 'all' ? tasks : tasks.filter((task) => task.status === taskFilter);
const prdContent = getPrdContent(project);
const tabs: Array<{ id: Tab; label: string }> = [
{ id: 'overview', label: 'Overview' },
{
id: 'tasks',
label: `Tasks (${projectTasks === null ? UNKNOWN_VERDICT : projectTasks.length})`,
},
{
id: 'missions',
label: `Missions (${projectMissions === null ? UNKNOWN_VERDICT : projectMissions.length})`,
},
{ id: 'tasks', label: `Tasks (${tasks.length})` },
{ id: 'missions', label: `Missions (${missions.length})` },
...(prdContent ? [{ id: 'prd' as const, label: 'PRD' }] : []),
];
const staleSnapshot: FreshSnapshot<unknown> | null =
project.freshness === 'stale'
? project.snapshot
: missions.freshness === 'stale'
? missions.snapshot
: tasks.freshness === 'stale'
? tasks.snapshot
: null;
const missingSections: string[] = [];
if (missions.freshness === 'unavailable') missingSections.push('Missions');
if (tasks.freshness === 'unavailable') missingSections.push('Tasks');
return (
<div data-freshness={surface} className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
<button
@@ -220,64 +152,49 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
Projects
</button>
<span>/</span>
<span className="text-text-primary">{project.data.name}</span>
<span className="text-text-primary">{project.name}</span>
</nav>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-text-primary">{project.data.name}</h1>
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs',
projectStatusColors[project.data.status] ?? 'bg-gray-600/20 text-gray-400',
projectStatusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
)}
>
{project.data.status}
{project.status}
</span>
</div>
{project.data.description ? (
<p className="mt-1 text-sm text-text-muted">{project.data.description}</p>
{project.description ? (
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
) : null}
<p className="mt-2 text-xs text-text-muted">
Created {new Date(project.data.createdAt).toLocaleDateString()} · Updated{' '}
{new Date(project.data.updatedAt).toLocaleDateString()}
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
{new Date(project.updatedAt).toLocaleDateString()}
</p>
</div>
</div>
</header>
{staleSnapshot !== null ? (
<div className="mb-6">
<StaleDataNotice label={staleSnapshot} onRetry={retryAll} />
</div>
) : null}
{missingSections.length > 0 ? (
<div className="mb-6">
<PartialDataNotice missing={missingSections} onRetry={retryAll} />
</div>
) : null}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label="Tasks"
value={projectTasks === null ? UNKNOWN_VERDICT : String(projectTasks.length)}
/>
<StatCard label="Tasks" value={String(tasks.length)} />
<StatCard
label="Done"
value={verdictValue(tasksVerified, String(doneCount))}
valueClass={tasksVerified ? 'text-success' : undefined}
value={String(tasks.filter((task) => task.status === 'done').length)}
valueClass="text-success"
/>
<StatCard
label="In Progress"
value={verdictValue(tasksVerified, String(inProgressCount))}
valueClass={tasksVerified ? 'text-blue-400' : undefined}
value={String(tasks.filter((task) => task.status === 'in-progress').length)}
valueClass="text-blue-400"
/>
<StatCard
label="Blocked"
value={verdictValue(tasksVerified, String(blockedCount))}
valueClass={tasksVerified && blockedCount > 0 ? 'text-error' : undefined}
value={String(tasks.filter((task) => task.status === 'blocked').length)}
valueClass={tasks.some((task) => task.status === 'blocked') ? 'text-error' : undefined}
/>
</div>
@@ -294,43 +211,23 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
</div>
{activeTab === 'overview' ? (
<OverviewTab project={project.data} missions={projectMissions} tasks={projectTasks} />
<OverviewTab project={project} missions={missions} tasks={tasks} />
) : null}
{activeTab === 'tasks' ? (
<div>
{projectTasks === null ? (
<UnavailableDataNotice
title="Tasks"
detail={describeFailure(tasks.failure)}
onRetry={retryAll}
<div className="mb-4">
<TaskStatusSummary
tasks={tasks}
activeFilter={taskFilter}
onFilterChange={setTaskFilter}
/>
) : (
<>
<div className="mb-4">
<TaskStatusSummary
tasks={projectTasks}
activeFilter={taskFilter}
onFilterChange={setTaskFilter}
/>
</div>
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
</>
)}
</div>
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
</div>
) : null}
{activeTab === 'missions' ? (
projectMissions === null ? (
<UnavailableDataNotice
title="Missions"
detail={describeFailure(missions.failure)}
onRetry={retryAll}
/>
) : (
<MissionTimeline missions={projectMissions} />
)
) : null}
{activeTab === 'missions' ? <MissionTimeline missions={missions} /> : null}
{activeTab === 'prd' && prdContent ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
@@ -351,26 +248,18 @@ function OverviewTab({
tasks,
}: {
project: Project;
missions: Mission[] | null;
tasks: Task[] | null;
missions: Mission[];
tasks: Task[];
}): ReactElement {
const recentTasks =
tasks === null
? null
: [...tasks]
.sort(
(left, right) =>
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
)
.slice(0, 5);
const recentTasks = [...tasks]
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
.slice(0, 5);
return (
<div className="grid gap-6 lg:grid-cols-2">
<section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
{recentTasks === null ? (
<UnavailableDataNotice title="Tasks" />
) : recentTasks.length === 0 ? (
{recentTasks.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No tasks yet</p>
</div>
@@ -398,9 +287,7 @@ function OverviewTab({
<section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
{missions === null ? (
<UnavailableDataNotice title="Missions" />
) : missions.length === 0 ? (
{missions.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No missions yet</p>
</div>
+3 -69
View File
@@ -51,7 +51,6 @@ afterEach(async () => {
document.body.replaceChildren();
root = null;
apiMock.mockReset();
sessionStorage.clear();
});
async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> {
@@ -72,22 +71,6 @@ async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRoute
return router;
}
function clickButtonByText(text: string): void {
const button = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes(text),
);
if (!button) {
throw new Error(`Button containing "${text}" not found`);
}
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
async function flushAct(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
describe('ProjectsPage', () => {
it('shows a visible loading state while the project request is in flight', async () => {
const deferred = createDeferred<typeof projectFixtures>();
@@ -108,7 +91,7 @@ describe('ProjectsPage', () => {
const router = await renderProjectsPage();
expect(apiMock.mock.calls[0]?.[0]).toBe('/api/projects');
expect(apiMock).toHaveBeenCalledWith('/api/projects');
expect(container.textContent).toContain('Mosaic Stack');
expect(container.textContent).toContain('Agent Runtime');
@@ -125,7 +108,7 @@ describe('ProjectsPage', () => {
expect(container.textContent).toContain('Project detail target');
});
it('renders the empty state only for a verified empty collection', async () => {
it('renders the empty state when the API returns no projects', async () => {
apiMock.mockResolvedValueOnce([]);
await renderProjectsPage();
@@ -134,12 +117,9 @@ describe('ProjectsPage', () => {
expect(container.textContent).toContain(
'Projects will appear here when created via the gateway API',
);
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
it('renders a failed fetch as an explicit unavailable state, never an empty collection', async () => {
it('renders a visible alert when the projects request fails', async () => {
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
await renderProjectsPage();
@@ -147,51 +127,5 @@ describe('ProjectsPage', () => {
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Projects are unavailable');
expect(alert?.textContent).toContain('not an empty result');
// Negative controls: no healthy empty state and no project cards render
// from a failed fetch.
expect(container.textContent).not.toContain('No projects yet');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
});
it('renders an auth failure as unavailable and recovers after retry', async () => {
apiMock
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { statusCode: 401 }))
.mockResolvedValueOnce(projectFixtures);
await renderProjectsPage();
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Unauthorized');
expect(container.textContent).not.toContain('No projects yet');
await act(async () => {
clickButtonByText('Retry');
});
await flushAct();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).toContain('Mosaic Stack');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
it('renders a schema-mismatched response as unavailable, never as data', async () => {
apiMock.mockResolvedValueOnce({ results: projectFixtures });
await renderProjectsPage();
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('not an empty result');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.textContent).not.toContain('No projects yet');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
});
});
+34 -32
View File
@@ -1,51 +1,53 @@
import { type ReactElement } from 'react';
import { useEffect, useState, type ReactElement } from 'react';
import { useNavigate } from 'react-router-dom';
import { ProjectCard } from '@/components/projects/project-card';
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api';
import type { Project } from '@/lib/types';
import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection';
import { validateProjectCollection } from '@/lib/freshness/validators';
import { getErrorMessage } from './page-errors';
export function ProjectsPage(): ReactElement {
const navigate = useNavigate();
const projects = useFreshCollection<Project[]>({
source: 'gateway:/api/projects',
fetcher: (signal) => api<unknown>('/api/projects', { signal }),
validate: validateProjectCollection,
// Projects carry workspace identity (userId) that is only knowable from
// the payload itself, so a restored entry cannot be scope-checked before
// display. Conservative choice: no last-known restore for this surface;
// cross-workspace switching is still invalidated at verification time.
});
const retry = (): void => {
void projects.revalidate();
};
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void api<Project[]>('/api/projects')
.then((response) => {
if (cancelled) return;
setProjects(response);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load projects.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<div
data-freshness={projects.freshness}
className="flex min-h-screen flex-col px-4 py-6 sm:px-6"
>
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Projects</h1>
</header>
{projects.freshness === 'stale' && projects.snapshot ? (
<div className="mb-6">
<StaleDataNotice label={projects.snapshot} onRetry={retry} />
{error ? (
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
{error}
</div>
) : null}
{projects.freshness === 'unknown' ? (
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
) : projects.freshness === 'unavailable' ? (
<UnavailableDataNotice
title="Projects"
detail={describeFailure(projects.failure)}
onRetry={retry}
/>
) : projects.data !== null && projects.data.length === 0 ? (
) : projects.length === 0 ? (
<div className="py-12 text-center">
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
<p className="mt-1 text-sm text-text-muted">
@@ -54,7 +56,7 @@ export function ProjectsPage(): ReactElement {
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{(projects.data ?? []).map((project) => (
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
+1 -87
View File
@@ -3,9 +3,6 @@ import { createRoot, type Root } from 'react-dom/client';
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { taskFixtures } from './page-fixtures';
import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from '@/lib/freshness/model';
import { writeSnapshotCache } from '@/lib/freshness/snapshot-cache';
import { validateTaskCollection } from '@/lib/freshness/validators';
const { apiMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
@@ -51,7 +48,6 @@ afterEach(async () => {
document.body.replaceChildren();
root = null;
apiMock.mockReset();
sessionStorage.clear();
});
async function renderTasksPage(): Promise<void> {
@@ -76,13 +72,6 @@ function clickButtonByText(text: string): void {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
/** Flush pending promise callbacks inside the act environment. */
async function flushAct(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
describe('TasksPage', () => {
it('shows a visible loading state before the tasks request settles', async () => {
const deferred = createDeferred<typeof taskFixtures>();
@@ -143,7 +132,7 @@ describe('TasksPage', () => {
expect(container.textContent).toContain('Wire list and kanban modal interactions');
});
it('renders a failed fetch as an explicit unavailable state, never an empty healthy board', async () => {
it('renders a visible alert when the tasks request fails', async () => {
apiMock.mockRejectedValueOnce(new Error('Tasks request failed'));
await renderTasksPage();
@@ -151,80 +140,5 @@ describe('TasksPage', () => {
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Tasks request failed');
expect(alert?.textContent).toContain('not an empty result');
// Negative controls: no board, no healthy empty-state markers, and the
// surface is marked unavailable rather than current.
expect(container.textContent).not.toContain('Not Started');
expect(container.textContent).not.toContain('No tasks');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
});
it('recovers to a current board after retrying a failed fetch', async () => {
apiMock
.mockRejectedValueOnce(new Error('Tasks request failed'))
.mockResolvedValueOnce(taskFixtures);
await renderTasksPage();
expect(container.querySelector('[role="alert"]')).toBeTruthy();
await act(async () => {
clickButtonByText('Retry');
});
await flushAct();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).toContain('Not Started');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
it('labels restored last-known data as stale with source, version, and age until verified', async () => {
// Seed a last-known snapshot fetched five minutes ago; the page must
// render it only under an explicit staleness label while the fetch is
// still in flight.
const restored = acceptSnapshot({
value: taskFixtures,
validate: validateTaskCollection,
previous: null,
policy: DEFAULT_FRESHNESS_POLICY,
source: 'gateway:/api/tasks',
now: Date.now() - 5 * 60_000,
});
if (restored.outcome !== 'accepted') throw new Error('fixture setup failed');
writeSnapshotCache('tasks', restored.snapshot);
const deferred = createDeferred<typeof taskFixtures>();
apiMock.mockReturnValueOnce(deferred.promise);
await renderTasksPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'stale',
);
const banner = container.querySelector('[role="status"]');
expect(banner?.textContent).toContain('last-known');
expect(banner?.textContent).toContain('may be out of date');
expect(banner?.textContent).toContain('gateway:/api/tasks');
expect(banner?.textContent).toContain('snapshot v1');
expect(banner?.textContent).toContain('5m ago');
// Last-known data still renders as situational awareness under the label.
expect(container.textContent).toContain('Route /tasks');
expect(container.textContent).not.toContain('Loading tasks...');
// Verification lands: the banner clears and the surface becomes current.
await act(async () => {
deferred.resolve(taskFixtures);
await deferred.promise;
});
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
});
+33 -26
View File
@@ -1,32 +1,45 @@
import { useState, type ReactElement } from 'react';
import { useEffect, useState, type ReactElement } from 'react';
import { KanbanBoard } from '@/components/tasks/kanban-board';
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
import { TaskListView } from '@/components/tasks/task-list-view';
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection';
import { validateTaskCollection } from '@/lib/freshness/validators';
import { getErrorMessage } from './page-errors';
type ViewMode = 'list' | 'kanban';
export function TasksPage(): ReactElement {
const tasks = useFreshCollection<Task[]>({
source: 'gateway:/api/tasks',
fetcher: (signal) => api<unknown>('/api/tasks', { signal }),
validate: validateTaskCollection,
cacheKey: 'tasks',
});
const [tasks, setTasks] = useState<Task[]>([]);
const [view, setView] = useState<ViewMode>('kanban');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const retry = (): void => {
void tasks.revalidate();
};
useEffect(() => {
let cancelled = false;
void api<Task[]>('/api/tasks')
.then((response) => {
if (cancelled) return;
setTasks(response);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load tasks.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<div data-freshness={tasks.freshness} className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 flex items-center justify-between gap-4 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Tasks</h1>
<div className="flex rounded-lg border border-surface-border">
@@ -57,24 +70,18 @@ export function TasksPage(): ReactElement {
</div>
</header>
{tasks.freshness === 'stale' && tasks.snapshot ? (
<div className="mb-6">
<StaleDataNotice label={tasks.snapshot} onRetry={retry} />
{error ? (
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
{error}
</div>
) : null}
{tasks.freshness === 'unknown' ? (
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
) : tasks.freshness === 'unavailable' ? (
<UnavailableDataNotice
title="Tasks"
detail={describeFailure(tasks.failure)}
onRetry={retry}
/>
) : view === 'kanban' ? (
<KanbanBoard tasks={tasks.data ?? []} onTaskClick={setSelectedTask} />
<KanbanBoard tasks={tasks} onTaskClick={setSelectedTask} />
) : (
<TaskListView tasks={tasks.data ?? []} onTaskClick={setSelectedTask} />
<TaskListView tasks={tasks} onTaskClick={setSelectedTask} />
)}
{selectedTask ? (
+31 -1
View File
@@ -30,7 +30,7 @@ The Gitea API token is **never passed on a curl command line.** An `Authorizatio
### `--login` override
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation (as of #1280, `pr-create.sh`, `pr-merge.sh` and `issue-create.sh` accept it too, and it wins over `MOSAIC_GIT_IDENTITY` everywhere). The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
@@ -58,6 +58,36 @@ token file present, both tools fall through to the existing shared-account path
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
tokens.
### Identity-first principal resolution in the wrappers (#1280)
`resolve_gitea_principal()` (detect-platform.sh) gives the write wrappers —
`pr-create.sh`, `pr-merge.sh`, `pr-review.sh`, `issue-create.sh`, `issue-comment.sh`
ONE precedence for choosing the acting principal:
1. an explicit `--login <name>` (now accepted by all five; operator intent beats
environment), then
2. the per-agent identity above (`MOSAIC_GIT_IDENTITY` env / worktree
`mosaic.gitIdentity`) when a per-slot token exists — the wrapper then writes via the
REST API with that identity's token and never consults `tea`, so the tea login list
cannot shadow the requested principal, then
3. the tea login list — the LAST resort, never the first, because it enumerates
whatever logins the host happens to hold and knows nothing about which seat is
calling.
A requested identity whose per-slot token is absent, or a `--login` whose token cannot
resolve host-bound, **fails loud** (nonzero, naming the identity/login and the expected
slot) instead of silently writing under whatever account `tea` has configured — that
silent fallthrough is defect #1280 (reviews, comments, merges, PRs and issues filed
under the wrong account). `pr-merge.sh --dry-run` reports the principal the merge would
act as, resolved exactly as the real merge resolves it. ⚠ A **workstation-global**
`mosaic.gitIdentity` shadows every seat on that host (a fresh clone with no local value
resolves the global one) — set it per-worktree, not with `--global`.
The resolver is covered by `test-gitea-principal-resolution.sh`; the happy-path
ordering (identity arm REACHED, not sitting behind a tea failure) by
`test-pr-create-identity-first.sh`; merge credential binding by
`test-pr-merge-principal-resolution.sh`.
### Enabling it for a clone
The framework installer syncs `git-credential-mosaic` to
@@ -497,6 +497,32 @@ get_gitea_url_for_host() {
return 1
}
# Map a Gitea host to the per-agent identity-token slot PREFIX ("gitea-usc" /
# "gitea-mosaicstack") used by identity-first principal resolution
# (MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity; #1280). Returns 1 for
# hosts with no per-slot scheme — callers treat that as "identity does not
# bind here" and fall through to existing behavior, never as an error. This is
# the single source of truth for the slot layout: get_gitea_token and
# resolve_gitea_principal both derive their slot paths from here, so the two
# resolutions can never disagree about where an identity's credential lives.
gitea_identity_slot_prefix() {
case "$1" in
git.uscllc.com) echo "gitea-usc" ;;
git.mosaicstack.dev) echo "gitea-mosaicstack" ;;
*) return 1 ;;
esac
}
# Resolve the per-slot token FILE PATH for an identity on a host. Prints the
# absolute path on success; returns 1 (no output) when the host has no per-slot
# scheme. Prints a PATH only — never a token value.
gitea_identity_token_slot() {
local identity="$1" host="$2" prefix
[[ -n "$identity" ]] || return 1
prefix=$(gitea_identity_slot_prefix "$host") || return 1
printf '%s\n' "$HOME/.config/mosaic/secrets/gitea-tokens/${prefix}-${identity}.token"
}
# Resolve a Gitea API token for the given host.
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
get_gitea_token() {
@@ -517,13 +543,8 @@ get_gitea_token() {
_ident_src="git config mosaic.gitIdentity"
fi
if [[ -n "$_ident" ]]; then
local _idpfx=""
case "$host" in
git.uscllc.com) _idpfx=gitea-usc ;;
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
esac
if [[ -n "$_idpfx" ]]; then
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
local _idtok=""
if _idtok="$(gitea_identity_token_slot "$_ident" "$host" 2>/dev/null)"; then
if [[ -r "$_idtok" ]]; then
cat "$_idtok"
return 0
@@ -1465,6 +1486,81 @@ raise SystemExit(1)
PY
}
# resolve_gitea_principal — identity-first acting-principal resolution shared by
# the git wrappers (#1280). The defect this fixes: wrappers resolved their
# acting principal from tea's login list FIRST, and that list enumerates
# whatever logins happen to be configured on the host — it knows nothing about
# which seat is calling — so a wrapper invoked with MOSAIC_GIT_IDENTITY=fargo
# still wrote under whichever account tea held (mos-dt-0), and the correct
# identity-aware code sat behind arms that only ran when the tea path failed.
# Precedence here is the contract:
# 1. an explicit login override ($1, the wrapper's --login) — operator intent
# beats environment;
# 2. MOSAIC_GIT_IDENTITY env, else per-worktree `git config mosaic.gitIdentity`
# (mirroring get_gitea_token exactly, so resolver and token resolution can
# never disagree) — binds only on hosts with a per-slot token scheme;
# 3. the tea login list — LAST resort, never the first.
#
# Prints exactly one line, three tab-separated fields (machine-readable for
# wrapper dispatch and tests):
# mode "login" | "identity" | "default"
# principal login name (login) | identity name (identity) | tea login or "" (default)
# source "tea-login:<name>" | "identity-slot:<path>" | "tea-default" | "host-credential"
#
# Fails LOUD (nonzero, empty stdout, stderr diagnostic) when an explicit
# override cannot be honored — a refusal is a good day; silently falling
# through to whoever tea has configured is the exact defect this resolves:
# - login mode: no host-bound token for that tea login. The existence check
# runs the same tea-config lookup tea itself uses; the token VALUE is
# discarded (never printed, never used).
# - identity mode: no per-slot token file for that identity on a recognized
# host — the diagnostic names the identity, its source, and the expected
# slot path. An identity requested on a host with NO per-slot scheme does
# not bind (matching get_gitea_token's containment) and falls to default.
#
# NEVER prints a token value — principal names and slot paths only.
# $1 = explicit login override ("" when absent), $2 = host (default: the
# origin remote's host).
resolve_gitea_principal() {
local login_override="${1:-}" host="${2:-}" ident ident_src slot login
[[ -n "$host" ]] || { host=$(get_remote_host) || return 1; }
if [[ -n "$login_override" ]]; then
get_gitea_token_for_login "$login_override" "$host" >/dev/null || {
echo "Error: --login '$login_override' has no host-matched token on host '$host' (tea config lookup); refusing to fall back to any other principal (#1280 identity-first resolution)." >&2
return 1
}
printf 'login\t%s\ttea-login:%s\n' "$login_override" "$login_override"
return 0
fi
ident="${MOSAIC_GIT_IDENTITY:-}"
ident_src="MOSAIC_GIT_IDENTITY"
if [[ -z "$ident" ]]; then
ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
ident_src="git config mosaic.gitIdentity"
fi
if [[ -n "$ident" ]] && slot="$(gitea_identity_token_slot "$ident" "$host" 2>/dev/null)"; then
if [[ -r "$slot" ]]; then
printf 'identity\t%s\tidentity-slot:%s\n' "$ident" "$slot"
return 0
fi
echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but no per-slot token at $slot (#1280 identity-first resolution)." >&2
echo " Refusing to fall back to the tea login list or shared credentials. Provision the per-slot token, or unset the identity." >&2
return 1
fi
# No override requested: tea's login list is the LAST resort. Absence is
# not an error here — callers fall back to the host credential, exactly as
# they did before this resolver existed (preserved behavior).
if login=$(get_gitea_login_for_host "$host" 2>/dev/null); then
printf 'default\t%s\ttea-default\n' "$login"
else
printf 'default\t\thost-credential\n'
fi
return 0
}
# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials.
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
get_gitea_basic_auth() {
@@ -76,27 +76,36 @@ fi
detect_platform >/dev/null
# Resolve and cache the Gitea REST endpoint + token for the current remote,
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
# bound to a SPECIFIC acting principal ($1) selected identity-first (#1280):
# an explicit --login wins, else MOSAIC_GIT_IDENTITY / git config
# mosaic.gitIdentity binds the per-slot credential, else the tea login list
# (last resort). Populates GITEA_API_ROOT (…/api/v1), GITEA_API_BASE
# (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
#
# The token is resolved for the EFFECTIVE login (the --login override when
# given, otherwise the detected default) so that the single credential used for
# the write ALSO drives the /user identity read and the read-back — write token
# and read-back token are the same identity by construction (this is the
# credential-ordering fix: a --login override is no longer written under one
# credential and verified under a different default one). Falls back to the
# host-scoped credential ONLY when NO --login override was supplied (the
# best-effort default path). When $2 is "explicit" the login came from a
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
# CLOSED rather than silently downgrading the write to the host default
# identity — otherwise a caller relying on a dedicated per-role credential would
# be told the write succeeded as requested while it was attributed to the shared
# default. Returns non-zero (clear stderr) on any resolution failure.
# The token is resolved for the EFFECTIVE principal so that the single
# credential used for the write ALSO drives the /user identity read and the
# read-back — write token and read-back token are the same identity by
# construction (this is the credential-ordering fix: a --login override is no
# longer written under one credential and verified under a different default
# one). When $2 is "identity" the principal ($1) is a requested git identity:
# the token MUST resolve from that identity's per-slot token (get_gitea_token's
# identity arm), failing closed rather than borrowing the tea default login —
# the tea login list must never shadow a requested identity (#1280). When $2
# is "explicit" the principal came from a caller-supplied --login: that exact
# login's token MUST resolve, and we FAIL CLOSED rather than silently
# downgrading the write to the host default identity. Otherwise the best-effort
# default path applies (per-login token, else the host-scoped credential).
# Returns non-zero (clear stderr) on any resolution failure.
gitea_resolve_api_for_login() {
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
host=$(get_remote_host)
if [[ -n "$override_explicit" ]]; then
if [[ "$override_explicit" == "identity" ]]; then
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: could not resolve the per-slot token for requested git identity '$effective_login' on host '$host'; refusing to fall back to the tea login list or shared credentials (comment write/read-back, #1280)." >&2
return 1
}
elif [[ -n "$override_explicit" ]]; then
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2
return 1
@@ -318,23 +327,31 @@ if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
# Resolve the login this comment should be attributed to: the --login
# override when given, otherwise the detected default for this repo's host.
# A --login override always wins. Otherwise name this repo host's login only
# as a best effort: the login name merely selects a per-login token, and
# gitea_resolve_api_for_login falls back to the host credential
# (get_gitea_token) when no tea login is named, so the default credential
# still resolves even when the host tea has no matching login entry.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
# Resolve the acting principal identity-first (#1280): an explicit --login
# wins; otherwise MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity
# selects the principal when a per-slot token exists (fail-loud when it
# does not); the tea login list is the LAST resort — it knows nothing about
# which seat is calling, so resolving from it first wrote under whichever
# account tea had configured (the #1280 family).
principal_host=$(get_remote_host)
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$principal_host")"; then
# resolve_gitea_principal already printed the fail-loud diagnostic.
exit 1
fi
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
# Bind the REST endpoint + token to the effective login, then derive the
# Bind the REST endpoint + token to the resolved principal, then derive the
# acting identity from that SAME credential (GET /user). The write below and
# its read-back both use this credential, so the write is verified against
# the identity that actually performed it. Passing "explicit" when --login
# was supplied forbids the host-default fallback: an unresolvable explicit
# override fails closed instead of writing under the default identity.
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
# the identity that actually performed it.
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
else
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
fi
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
@@ -1,6 +1,15 @@
#!/bin/bash
# issue-create.sh - Create issues on Gitea or GitHub
# Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"]
# Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"] [--login <name>]
#
# Acting principal is resolved identity-first (#1280): an explicit --login
# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config
# mosaic.gitIdentity selects the principal when a per-slot token exists (and
# the wrapper then creates the issue through the REST API with that identity's
# token — tea is never invoked, so the tea login list cannot shadow the
# requested principal); the tea login list is the LAST resort. A requested
# identity with no per-slot token fails LOUD rather than writing under
# whichever account tea happens to hold.
set -e
@@ -16,6 +25,14 @@ INTERACTIVE=false
# get_remote_host and get_gitea_token are provided by detect-platform.sh
# Acting-principal mode set in the Gitea branch below (from
# resolve_gitea_principal): "login" when --login was given, "identity" when a
# git identity bound, "default" otherwise. PRINCIPAL_MODE=login makes the API
# arm resolve the --login principal's token too, so an explicit --login keeps
# winning even on the tea-FAILURE fallback arm.
PRINCIPAL_MODE=""
PRINCIPAL_NAME=""
gitea_issue_create_api() {
local host repo token url payload
host=$(get_remote_host) || {
@@ -26,10 +43,19 @@ gitea_issue_create_api() {
echo "Error: could not determine repo owner/name for API fallback" >&2
return 1
}
token=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
return 1
}
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
token=$(get_gitea_token_for_login "$PRINCIPAL_NAME" "$host") || {
echo "Error: could not resolve a host-matched Gitea token for --login '$PRINCIPAL_NAME' on host '$host' (API path)" >&2
return 1
}
else
# Identity-first when MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity
# is set (per-slot token, fail-loud on absence); shared default otherwise.
token=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
return 1
}
fi
if [[ -n "$LABELS" || -n "$MILESTONE" ]]; then
echo "Warning: API fallback currently applies title/body only; labels/milestone require authenticated tea setup." >&2
@@ -67,6 +93,7 @@ Options:
-b, --body BODY Issue body/description
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
-m, --milestone NAME Milestone name to assign
--login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY)
-i, --interactive Prompt for missing issue fields
-h, --help Show this help message
@@ -97,6 +124,10 @@ while [[ $# -gt 0 ]]; do
MILESTONE="$2"
shift 2
;;
--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-i|--interactive)
INTERACTIVE=true
shift
@@ -134,13 +165,37 @@ case "$PLATFORM" in
"${CMD[@]}"
;;
gitea)
# Resolve the acting principal identity-first (#1280). The tea login
# list is the LAST resort: it knows nothing about which seat is calling,
# and a login resolved from it first is what attributed issues to the
# wrong account even when MOSAIC_GIT_IDENTITY was set.
principal_host=$(get_remote_host 2>/dev/null || true)
if ! principal_resolved="$(resolve_gitea_principal "${LOGIN_OVERRIDE:-}" "$principal_host")"; then
# resolve_gitea_principal already printed the fail-loud diagnostic.
exit 1
fi
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
# HAPPY PATH for a requested identity: create through the REST API
# with the per-slot token and never invoke tea — the identity arm
# must be REACHED, not sit behind a tea failure (#1280).
gitea_issue_create_api
exit $?
fi
if command -v tea >/dev/null 2>&1; then
REPO_SLUG=$(get_repo_slug)
GITEA_LOGIN_NAME=$(get_gitea_login) || {
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
gitea_issue_create_api
exit $?
}
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
GITEA_LOGIN_NAME="$PRINCIPAL_NAME"
else
GITEA_LOGIN_NAME=$(get_gitea_login) || {
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
gitea_issue_create_api
exit $?
}
fi
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
gitea_issue_create_api
@@ -1,6 +1,15 @@
#!/bin/bash
# pr-create.sh - Create pull requests on Gitea or GitHub
# Usage: pr-create.sh -t "Title" [-b "Body"] [-B base] [-H head] [-l "labels"] [-m "milestone"]
# Usage: pr-create.sh -t "Title" [-b "Body"] [-B base] [-H head] [-l "labels"] [-m "milestone"] [--login <name>]
#
# Acting principal is resolved identity-first (#1280): an explicit --login
# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config
# mosaic.gitIdentity selects the principal when a per-slot token exists (and
# the wrapper then creates the PR through the REST API with that identity's
# token — tea is never invoked, so the tea login list cannot shadow the
# requested principal); the tea login list is the LAST resort. A requested
# identity with no per-slot token fails LOUD rather than writing under
# whichever account tea happens to hold.
set -e
@@ -19,6 +28,15 @@ ISSUE=""
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh
# Acting-principal mode set in the Gitea branch below (from
# resolve_gitea_principal): "login" when --login was given, "identity" when a
# git identity bound, "default" otherwise. PRINCIPAL_MODE=login makes the API
# arm resolve the --login principal's token too, so an explicit --login keeps
# winning even on the tea-FAILURE fallback arm (otherwise the fallback would
# silently re-resolve to the environment identity or shared credential).
PRINCIPAL_MODE=""
PRINCIPAL_NAME=""
gitea_pr_create_api() {
local host repo token url payload
host=$(get_remote_host) || {
@@ -29,10 +47,19 @@ gitea_pr_create_api() {
echo "Error: could not determine repo owner/name for API fallback" >&2
return 1
}
token=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
return 1
}
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
token=$(get_gitea_token_for_login "$PRINCIPAL_NAME" "$host") || {
echo "Error: could not resolve a host-matched Gitea token for --login '$PRINCIPAL_NAME' on host '$host' (API path)" >&2
return 1
}
else
# Identity-first when MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity
# is set (per-slot token, fail-loud on absence); shared default otherwise.
token=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
return 1
}
fi
if [[ -n "$LABELS" || -n "$MILESTONE" || "$DRAFT" == true ]]; then
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
@@ -76,6 +103,7 @@ Options:
-H, --head BRANCH Head branch with changes (default: current branch)
-l, --labels LABELS Comma-separated labels
-m, --milestone NAME Milestone name
--login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY)
-i, --issue NUMBER Link to issue (auto-generates title if not provided)
-d, --draft Create as draft PR
-h, --help Show this help message
@@ -116,6 +144,10 @@ while [[ $# -gt 0 ]]; do
MILESTONE="$2"
shift 2
;;
--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-i|--issue)
ISSUE="$2"
shift 2
@@ -174,15 +206,41 @@ case "$PLATFORM" in
"${CMD[@]}"
;;
gitea)
# Resolve the acting principal identity-first (#1280). The tea login
# list is the LAST resort: it knows nothing about which seat is calling,
# and a login resolved from it first is what attributed PRs to the wrong
# account even when MOSAIC_GIT_IDENTITY was set.
principal_host=$(get_remote_host 2>/dev/null || true)
if ! principal_resolved="$(resolve_gitea_principal "${LOGIN_OVERRIDE:-}" "$principal_host")"; then
# resolve_gitea_principal already printed the fail-loud diagnostic.
exit 1
fi
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
# HAPPY PATH for a requested identity: the per-slot token IS the
# credential, so create through the REST API directly and never
# invoke tea — the identity arm must be REACHED, not sit behind a
# tea failure (#1280). Fail-loud on a missing slot already happened
# in resolve_gitea_principal.
gitea_pr_create_api
exit $?
fi
# tea pull create syntax. Always pass --repo because tea repo inference
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead
# of eval so markdown backticks/body content are not shell-executed.
REPO_SLUG=$(get_repo_slug)
GITEA_LOGIN_NAME=$(get_gitea_login) || {
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
gitea_pr_create_api
exit $?
}
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
GITEA_LOGIN_NAME="$PRINCIPAL_NAME"
else
GITEA_LOGIN_NAME=$(get_gitea_login) || {
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
gitea_pr_create_api
exit $?
}
fi
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
gitea_pr_create_api
@@ -1,6 +1,13 @@
#!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL] [--login <name>]
#
# Acting principal is resolved identity-first (#1280): an explicit --login
# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config
# mosaic.gitIdentity selects the credential (per-slot token, fail-loud when
# absent); the shared host credential is the last resort. The merge is
# performed with the resolved credential only — never a cross-principal
# fallback (an HTTP 401 from the identity-bound token is a hard stop).
set -euo pipefail
@@ -16,6 +23,7 @@ DRY_RUN=false
EXPECT_HEAD=""
CO_AUTHOR_TRAILERS=false
ESCALATE_TO=""
LOGIN_OVERRIDE=""
usage() {
cat <<EOF
@@ -31,6 +39,7 @@ Options:
--expect-head SHA Refuse unless the PR head matches this full commit SHA
--co-author-trailers Build verified trailers from linked PR commit authors
--escalate-to NAME Named principal for an unresolved-author BLOCK
--login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY)
-h, --help Show this help message
Examples:
@@ -39,6 +48,7 @@ Examples:
$(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
$(basename "$0") -n 42 --login fred-ms # Merge under the fred-ms tea login
EOF
exit "${1:-1}"
}
@@ -82,6 +92,14 @@ while [[ $# -gt 0 ]]; do
ESCALATE_TO="$2"
shift 2
;;
--login|-l)
if [[ $# -lt 2 ]]; then
echo "Error: --login requires one tea login name." >&2
exit 1
fi
LOGIN_OVERRIDE="$2"
shift 2
;;
-h|--help)
usage 0
;;
@@ -572,9 +590,22 @@ PY
merge_gitea_with_api() {
local host="$1" token attempt_rc
if ! token=$(get_gitea_token "$host"); then
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
return 1
# Identity-first principal resolution (#1280): an explicit --login wins
# over MOSAIC_GIT_IDENTITY (operator intent beats environment); otherwise
# get_gitea_token resolves the identity's per-slot token when an identity
# is requested (fail-loud when absent) and the shared host credential only
# when no identity is set. No cross-principal fallback: whatever resolves
# here is the ONLY credential the merge is attempted with.
if [[ -n "$LOGIN_OVERRIDE" ]]; then
if ! token=$(get_gitea_token_for_login "$LOGIN_OVERRIDE" "$host"); then
echo "Error: --login '$LOGIN_OVERRIDE' has no host-matched token on host '$host'; refusing to merge under any other principal (#1280 identity-first resolution)." >&2
return 1
fi
else
if ! token=$(get_gitea_token "$host"); then
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
return 1
fi
fi
if [[ -z "$token" ]]; then
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
@@ -602,10 +633,25 @@ if [[ "$DRY_RUN" == true ]]; then
echo "Error: Cannot determine host from origin remote URL" >&2
exit 1
}
# Report the acting principal the merge WOULD use, resolved the same
# way the real merge resolves it (#1280) — a dry run that names a
# different principal than the merge would act as is a lie.
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$HOST")"; then
# Fail-loud diagnostic already printed (unresolvable --login or a
# requested identity with no per-slot token).
exit 1
fi
DRY_PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
DRY_PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
case "$DRY_PRINCIPAL_MODE" in
login) DRY_PRINCIPAL_DESC="tea login '$DRY_PRINCIPAL_NAME'" ;;
identity) DRY_PRINCIPAL_DESC="git identity '$DRY_PRINCIPAL_NAME' (per-slot credential)" ;;
*) DRY_PRINCIPAL_DESC="default host credential" ;;
esac
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST as $DRY_PRINCIPAL_DESC with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
else
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
echo "Dry run: would merge PR #$PR_NUMBER on $HOST as $DRY_PRINCIPAL_DESC with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
fi
else
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
@@ -76,7 +76,7 @@ while [[ $# -gt 0 ]]; do
echo " -n, --number PR number (required)"
echo " -a, --action Review action: approve, request-changes, comment (required)"
echo " -c, --comment Review comment (required for request-changes)"
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
echo " -l, --login Override the detected Gitea tea login (all actions; wins over MOSAIC_GIT_IDENTITY)"
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
echo " -h, --help Show this help"
@@ -346,7 +346,14 @@ gitea_resolve_api_for_login() {
else
host=$(get_remote_host)
fi
if [[ -n "$override_explicit" ]]; then
if [[ "$override_explicit" == "identity" ]]; then
# Requested git identity (#1280): the per-slot token MUST resolve via
# get_gitea_token's identity arm; never borrow the tea default login.
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: could not resolve the per-slot token for requested git identity '$effective_login' on host '$host'; refusing to fall back to the tea login list or shared credentials (review write/read-back, #1280)." >&2
return 1
}
elif [[ -n "$override_explicit" ]]; then
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2
return 1
@@ -676,29 +683,32 @@ if [[ "$PLATFORM" == "github" ]]; then
;;
esac
elif [[ "$PLATFORM" == "gitea" ]]; then
# Resolve the acting principal ONCE for every action, identity-first
# (#1280): an explicit --login wins; otherwise MOSAIC_GIT_IDENTITY /
# per-worktree git config mosaic.gitIdentity selects the principal when a
# per-slot token exists (fail-loud when it does not); the tea login list is
# the LAST resort — it enumerates whatever logins this host happens to hold
# and knows nothing about which seat is calling, so resolving from it first
# wrote under whichever account tea had configured (the #1280 family).
principal_host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$principal_host")"; then
# resolve_gitea_principal already printed the fail-loud diagnostic.
exit 1
fi
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
case $ACTION in
approve)
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
# under `set -e`, with no origin and no -H, previously killed the script
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
# Bind the REST endpoint + token to the effective login, then derive
# the acting identity from that SAME credential so the review submit
# and its read-back verify against the identity that performed them.
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
# PRINCIPAL_NAME were resolved once above from --login >
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
else
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
fi
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
# The review body (if any) travels with the review itself in the REST
@@ -715,24 +725,16 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Error: Comment required for request-changes"
exit 1
fi
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
# under `set -e`, with no origin and no -H, previously killed the script
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
# PRINCIPAL_NAME were resolved once above from --login >
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
else
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
fi
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
@@ -746,24 +748,16 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Error: Comment required"
exit 1
fi
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
# under `set -e`, with no origin and no -H, previously killed the script
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
# PRINCIPAL_NAME were resolved once above from --login >
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
else
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
fi
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
@@ -0,0 +1,255 @@
#!/usr/bin/env bash
# Regression harness for detect-platform.sh's resolve_gitea_principal() — the
# identity-first acting-principal resolution shared by the git wrappers
# (mosaicstack/stack #1280).
#
# The contract under test (precedence: --login > MOSAIC_GIT_IDENTITY /
# git config mosaic.gitIdentity > tea login list, which is the LAST resort):
# 1. identity env + per-slot token present -> mode=identity, principal=
# identity name, source names the identity's slot PATH (never a token
# value).
# 2. identity env + per-slot token ABSENT -> FAIL LOUD: nonzero, empty
# stdout, stderr naming the identity and the expected slot path.
# 3. identity env + --login -> --login wins (login mode resolves even when
# the identity has no slot — operator intent beats environment).
# 4. identity unset + no --login -> default mode: the tea login list
# resolves the principal exactly as before (preserved behavior).
# 5. no identity + no host-matching tea login -> default/host-credential
# (preserved behavior; absence is not an error on the default path).
# 6. identity on an UNRECOGNIZED host (no per-slot scheme) -> does not bind;
# default mode (containment, mirroring get_gitea_token).
# 7. --login with no host-bound token for that login -> FAIL LOUD, stderr
# naming the login and the host.
# 8. git config mosaic.gitIdentity is honored when the env var is unset.
# 9. The resolver NEVER emits a token value — stdout/stderr of every
# successful resolution must not contain the slot file's contents.
#
# Uses a stubbed tea binary, stubbed tea config.yml, stubbed credentials.json
# and stubbed per-slot token files under a fake HOME. NEVER reads real secrets.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-principal-resolution}"
FAKE_HOME="$WORK_DIR/home"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
rm -rf "$WORK_DIR"
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" "$REPO_DIR" "$BIN_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
cat > "$CREDENTIALS_FILE" <<'JSON'
{
"gitea": {
"mosaicstack": {
"url": "https://git.mosaicstack.dev",
"token": "shared-mosaicstack-token"
},
"usc": {
"url": "https://git.uscllc.com",
"token": "shared-usc-token"
}
}
}
JSON
# tea's own config store: the source get_gitea_token_for_login reads. Logins
# "alice" (mosaicstack) and "bob-usc" (usc) carry sentinel token values that
# the assertions prove are NEVER emitted by the resolver.
cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML'
logins:
- name: alice
url: https://git.mosaicstack.dev
token: SECRET-alice-tea-token
- name: bob-usc
url: https://git.uscllc.com
token: SECRET-bob-usc-tea-token
YAML
# Stubbed tea: only what login resolution needs (`login list --output json`).
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == "login list --output json" ]]; then
cat <<'JSON'
[
{"name":"alice","url":"https://git.mosaicstack.dev","default":true},
{"name":"bob-usc","url":"https://git.uscllc.com"}
]
JSON
exit 0
fi
exit 0
SH
chmod +x "$BIN_DIR/tea"
# Per-slot identity token with a sentinel value the assertions prove is never
# emitted (proving "token came from the identity's slot BY PATH, not by value").
echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token"
fail=0
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" != "$actual" ]]; then
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
fail=1
fi
}
assert_contains() {
local desc="$1" haystack="$2" needle="$3"
if [[ "$haystack" != *"$needle"* ]]; then
echo "FAIL: $desc — missing '$needle' in: $haystack" >&2
fail=1
fi
}
assert_not_contains() {
local desc="$1" haystack="$2" needle="$3"
if [[ "$haystack" == *"$needle"* ]]; then
echo "FAIL: $desc — must not contain '$needle', got: $haystack" >&2
fail=1
fi
}
# Runs resolve_gitea_principal for $1=login_override $2=host inside REPO_DIR
# (per-worktree git config resolves there) under a fake HOME, stubbed tea, and
# stubbed credentials. Extra env (e.g. MOSAIC_GIT_IDENTITY) via $@.
call_resolver() {
local login="$1" host="$2"; shift 2
(
cd "$REPO_DIR"
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \
bash -c 'source "$DETECT_PLATFORM_SH"; resolve_gitea_principal "$1" "$2"' _ "$login" "$host"
)
}
field() { printf '%s' "$1" | cut -f"$2"; }
# ---------------------------------------------------------------------------
# 1. Identity env + slot present -> identity mode, slot named BY PATH, and no
# token value ever emitted.
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
out=$(call_resolver "" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentX)
assert_eq "identity mode" "identity" "$(field "$out" 1)"
assert_eq "identity principal" "agentX" "$(field "$out" 2)"
assert_eq "identity slot source" \
"identity-slot:$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token" \
"$(field "$out" 3)"
assert_not_contains "identity stdout leaks token" "$out" "SECRET"
# ---------------------------------------------------------------------------
# 2. Identity env + slot ABSENT -> fail loud: nonzero, empty stdout, stderr
# naming the identity and the expected slot path.
# ---------------------------------------------------------------------------
stderr_file="$WORK_DIR/stderr.tmp"
set +e
out=$(call_resolver "" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$stderr_file")
rc=$?
set -e
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: missing slot — expected nonzero return, got 0 (stdout='$out')" >&2
fail=1
fi
if [[ -n "$out" ]]; then
echo "FAIL: missing slot — expected empty stdout, got '$out'" >&2
fail=1
fi
err=$(cat "$stderr_file")
assert_contains "missing slot names identity" "$err" "agentNoSlot"
assert_contains "missing slot names slot path" "$err" \
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token"
assert_not_contains "missing-slot stderr leaks token" "$err" "SECRET"
# ---------------------------------------------------------------------------
# 3. Identity + --login -> --login wins. Also wins when the identity has NO
# slot (no identity check may veto an explicit login).
# ---------------------------------------------------------------------------
out=$(call_resolver "alice" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentX)
assert_eq "login beats identity (mode)" "login" "$(field "$out" 1)"
assert_eq "login beats identity (principal)" "alice" "$(field "$out" 2)"
assert_eq "login source" "tea-login:alice" "$(field "$out" 3)"
out=$(call_resolver "alice" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentNoSlot)
assert_eq "login beats slot-less identity" "login" "$(field "$out" 1)"
# ---------------------------------------------------------------------------
# 4. No identity, no --login -> default mode via the tea login list
# (preserved behavior).
# ---------------------------------------------------------------------------
out=$(call_resolver "" "git.mosaicstack.dev")
assert_eq "default mode" "default" "$(field "$out" 1)"
assert_eq "default principal" "alice" "$(field "$out" 2)"
assert_eq "default source" "tea-default" "$(field "$out" 3)"
# ---------------------------------------------------------------------------
# 5. No identity, no --login, no host-matching tea login -> default with the
# host credential (absence is not an error on the default path).
# ---------------------------------------------------------------------------
out=$(call_resolver "" "git.unknown.test")
assert_eq "no-match default mode" "default" "$(field "$out" 1)"
assert_eq "no-match default principal" "" "$(field "$out" 2)"
assert_eq "no-match default source" "host-credential" "$(field "$out" 3)"
# ---------------------------------------------------------------------------
# 6. Identity on an UNRECOGNIZED host -> does not bind; default mode
# (containment, mirroring get_gitea_token's scope).
# ---------------------------------------------------------------------------
out=$(call_resolver "" "github.com" MOSAIC_GIT_IDENTITY=agentX)
assert_eq "unrecognized host falls to default" "default" "$(field "$out" 1)"
# ---------------------------------------------------------------------------
# 7. --login with no host-bound token for that login -> fail loud, stderr
# naming the login and the host.
# ---------------------------------------------------------------------------
: > "$stderr_file"
set +e
out=$(call_resolver "ghost-login" "git.mosaicstack.dev" 2>"$stderr_file")
rc=$?
set -e
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: unknown --login — expected nonzero return, got 0 (stdout='$out')" >&2
fail=1
fi
err=$(cat "$stderr_file")
assert_contains "unknown login names login" "$err" "ghost-login"
assert_contains "unknown login names host" "$err" "git.mosaicstack.dev"
# A cross-host login (exists, but for usc) must ALSO fail loud for mosaicstack.
set +e
out=$(call_resolver "bob-usc" "git.mosaicstack.dev" 2>"$stderr_file")
rc=$?
set -e
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: cross-host --login — expected nonzero return, got 0" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 8. git config mosaic.gitIdentity honored when env is unset.
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config mosaic.gitIdentity agentX
out=$(call_resolver "" "git.mosaicstack.dev")
assert_eq "git-config identity mode" "identity" "$(field "$out" 1)"
assert_eq "git-config identity principal" "agentX" "$(field "$out" 2)"
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
# ---------------------------------------------------------------------------
# 9. Cross-host slot layout: the usc slot path is chosen for the usc host.
# ---------------------------------------------------------------------------
echo -n "SECRET-agentX-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentX.token"
out=$(call_resolver "" "git.uscllc.com" MOSAIC_GIT_IDENTITY=agentX)
assert_eq "usc identity mode" "identity" "$(field "$out" 1)"
assert_eq "usc slot source" \
"identity-slot:$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentX.token" \
"$(field "$out" 3)"
if [[ "$fail" -eq 0 ]]; then
echo "resolve_gitea_principal identity-first resolution regression passed"
fi
exit "$fail"
@@ -77,12 +77,30 @@ exit 0
SH
chmod +x "$BIN_DIR/tea"
# TRIPWIRE provider stub: this harness tests argv construction, so ANY curl
# call is a failure of that contract (and, before this stub existed, a LIVE
# write — the #1282#1287 incident: the seat's real HOME leaked a global
# mosaic.gitIdentity, flipping the wrapper into identity mode whose real
# per-slot token created real issues on the forge). Fail loudly instead.
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
echo "FAIL: body-safety harness reached a provider request — this test must never curl" >&2
exit 99
SH
chmod +x "$BIN_DIR/curl"
# Hermetic invocation: fake HOME (no credentials, no tea config, no token
# slots) and GIT_CONFIG_GLOBAL severed — `git config --get mosaic.gitIdentity`
# otherwise resolves the WORKSTATION's global identity (mos-dt-0 on the seat
# that wrote this) and reroutes the wrapper into identity mode (#1280 family).
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
env -i HOME="$WORK_DIR/home" PATH="$BIN_DIR:$PATH" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
) >/dev/null
mkdir -p "$WORK_DIR/home"
# 1. No command substitution executed anywhere in the pipeline.
if [[ -e "$SENTINEL" ]]; then
@@ -47,14 +47,31 @@ SH
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
run_wrapper() {
# Hermetic: fake HOME (fixture credentials only, no token slots, no tea
# config) and GIT_CONFIG_GLOBAL severed — `git config --get
# mosaic.gitIdentity` otherwise resolves the WORKSTATION's global identity
# and reroutes the wrapper into identity mode before the tea paths this
# harness exercises (#1280 family; see test-issue-create-body-safety.sh).
# An `env …` prefix (used for MOSAIC_TEA_STALE_USER) is re-wrapped, not
# doubled: arguments beginning with "env" are shifted past.
local env_pairs=()
if [[ "${1:-}" == "env" ]]; then
shift
while [[ "$#" -gt 0 && "$1" == *=* ]]; do
env_pairs+=("$1")
shift
done
fi
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
"$@"
env -i HOME="$WORK_DIR/home" PATH="$BIN_DIR:$PATH" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" "${env_pairs[@]}" \
"$@"
)
}
mkdir -p "$WORK_DIR/home"
: > "$LOG_FILE"
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
@@ -0,0 +1,244 @@
#!/usr/bin/env bash
# Load-bearing regression harness for pr-create.sh identity-first principal
# resolution (mosaicstack/stack #1280).
#
# The failure this harness is written down to catch: `MOSAIC_GIT_IDENTITY=fargo
# pr-create.sh …` produces a PR attributed to `mos-dt-0` (whichever account the
# tea login list happens to hold). Before #1280 the identity-aware code existed
# but sat on the API arm that only ran when the tea path FAILED — tea succeeded,
# so the identity arm never executed, and every test that did not check ORDERING
# passed. This harness checks ordering directly:
#
# 1. identity set + slot present -> the PR is created via the REST API with
# the identity's per-slot token (asserted by sentinel value AT the fake
# provider), and tea's `pr create` is NEVER invoked.
# 2. identity set + slot ABSENT -> nonzero, stderr naming the identity and
# the expected slot path; neither tea `pr create` nor any API request
# fires. No silent fallback to the tea login list.
# 3. identity set + --login -> --login wins: tea runs WITH the explicit
# --login, no API request.
# 4. nothing set -> preserved behavior: tea path with the tea-list login.
#
# Uses a stubbed tea, a stubbed curl provider, stubbed credentials.json and
# per-slot token under a fake HOME. NEVER reads real secrets or hits a live
# forge — all assertions are against the stubs' logs.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-identity-first}"
FAKE_HOME="$WORK_DIR/home"
REPO_DIR="$WORK_DIR/repo"
TOOLS_DIR="$WORK_DIR/tools"
BIN_DIR="$WORK_DIR/bin"
LOG_FILE="$WORK_DIR/calls.log"
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
rm -rf "$WORK_DIR"
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" \
"$REPO_DIR" "$TOOLS_DIR/git" "$TOOLS_DIR/_lib" "$BIN_DIR"
# Fixture: the real scripts under test, copied so sibling stubs (and the
# ../_lib credential loader) resolve inside the fixture tree.
cp "$SCRIPT_DIR/pr-create.sh" "$TOOLS_DIR/git/pr-create.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS_DIR/git/detect-platform.sh"
cp "$SCRIPT_DIR/../_lib/credentials.sh" "$TOOLS_DIR/_lib/credentials.sh"
chmod +x "$TOOLS_DIR/git/pr-create.sh"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
cat > "$CREDENTIALS_FILE" <<'JSON'
{
"gitea": {
"mosaicstack": {
"url": "https://git.mosaicstack.dev",
"token": "shared-mosaicstack-token"
}
}
}
JSON
cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML'
logins:
- name: alice
url: https://git.mosaicstack.dev
token: SECRET-alice-tea-token
YAML
echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token"
: > "$LOG_FILE"
# Stubbed tea: records every invocation; `login list` feeds login resolution;
# `api --login <n> /user` feeds get_gitea_authenticated_user; `pr create` marks
# the marker file (its presence fails the identity-mode assertions).
cat > "$BIN_DIR/tea" <<SH
#!/usr/bin/env bash
set -euo pipefail
printf 'TEA: %s\n' "\$*" >> "$LOG_FILE"
if [[ "\$*" == "login list --output json" ]]; then
cat <<'JSON'
[
{"name":"alice","url":"https://git.mosaicstack.dev","default":true}
]
JSON
exit 0
fi
if [[ "\${1:-}" == "api" ]]; then
printf '%s\n' '{"login":"alice"}'
exit 0
fi
if [[ "\$*" == pr\ create* ]]; then
echo "TEA-PR-CREATE-INVOKED" >> "$LOG_FILE"
exit 0
fi
exit 0
SH
chmod +x "$BIN_DIR/tea"
# Stubbed provider: records the URL and the Authorization header VALUE it
# received, answers 201 with a created-PR object. The sentinel token values are
# synthetic fixtures — asserting them at the provider proves WHICH slot's
# credential carried the write.
cat > "$BIN_DIR/curl" <<SH
#!/usr/bin/env bash
set -euo pipefail
url=""
auth=""
while [[ \$# -gt 0 ]]; do
case "\$1" in
-H)
case "\$2" in
Authorization*) auth="\$2" ;;
esac
shift 2
;;
*) [[ -n "\$1" && "\$1" != -* ]] && url="\$1"
shift
;;
esac
done
printf 'CURL-URL: %s\nCURL-AUTH: %s\n' "\$url" "\$auth" >> "$LOG_FILE"
cat <<'JSON'
{"number": 1299, "html_url": "https://git.mosaicstack.dev/mosaicstack/stack/pulls/1299"}
JSON
exit 0
SH
chmod +x "$BIN_DIR/curl"
fail=0
assert_contains() {
local desc="$1" needle="$2"
if ! grep -qF -- "$needle" "$LOG_FILE"; then
echo "FAIL: $desc — log does not contain '$needle':" >&2
cat "$LOG_FILE" >&2
fail=1
fi
}
assert_not_contains() {
local desc="$1" needle="$2"
if grep -qF -- "$needle" "$LOG_FILE"; then
echo "FAIL: $desc — log must not contain '$needle':" >&2
cat "$LOG_FILE" >&2
fail=1
fi
}
EXTRA_ARGS=""
run_pr_create() {
# "$@" carries ONLY environment assignments (VAR=value); EXTRA_ARGS (if
# set) carries wrapper arguments, so `env` never mistakes a wrapper flag
# like --login for one of its own.
(
cd "$REPO_DIR"
# shellcheck disable=SC2086 # EXTRA_ARGS is deliberately word-split wrapper args
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" "$@" \
bash "$TOOLS_DIR/git/pr-create.sh" -t "Test PR" -B next -H fix/test $EXTRA_ARGS
)
}
# ---------------------------------------------------------------------------
# 1. HAPPY PATH (the load-bearing ordering test): identity set + slot present
# -> REST API with the per-slot token; tea `pr create` NEVER invoked.
# ---------------------------------------------------------------------------
set +e
out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentX 2>"$WORK_DIR/stderr-1.tmp")
rc=$?
set -e
if [[ "$rc" -ne 0 ]]; then
echo "FAIL: identity happy path — expected rc=0, got $rc" >&2
cat "$WORK_DIR/stderr-1.tmp" >&2
fail=1
fi
assert_contains "identity happy path reaches the API" "CURL-URL: https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls"
assert_contains "identity happy path carries the slot token" "CURL-AUTH: Authorization: token SECRET-agentX-slot-token"
assert_not_contains "identity happy path must NOT invoke tea pr create" "TEA-PR-CREATE-INVOKED"
# ---------------------------------------------------------------------------
# 2. Identity set + slot ABSENT -> fail loud BEFORE any write: nonzero, stderr
# naming identity + slot path, no tea pr create, no API request.
# ---------------------------------------------------------------------------
: > "$LOG_FILE"
set +e
out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$WORK_DIR/stderr-2.tmp")
rc=$?
set -e
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: missing slot — expected nonzero return, got 0 (stdout='$out')" >&2
fail=1
fi
err=$(cat "$WORK_DIR/stderr-2.tmp")
if [[ "$err" != *"agentNoSlot"* ]]; then
echo "FAIL: missing slot — stderr does not name the identity:" >&2
echo "$err" >&2
fail=1
fi
if [[ "$err" != *"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token"* ]]; then
echo "FAIL: missing slot — stderr does not name the expected slot path:" >&2
echo "$err" >&2
fail=1
fi
assert_not_contains "missing slot must not reach tea pr create" "TEA-PR-CREATE-INVOKED"
assert_not_contains "missing slot must not reach the API" "CURL-URL"
# ---------------------------------------------------------------------------
# 3. Identity set + --login -> --login wins: tea runs WITH the explicit login.
# ---------------------------------------------------------------------------
: > "$LOG_FILE"
EXTRA_ARGS="--login alice"
set +e
out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentX 2>"$WORK_DIR/stderr-3.tmp")
rc=$?
set -e
EXTRA_ARGS=""
if [[ "$rc" -ne 0 ]]; then
echo "FAIL: login override — expected rc=0, got $rc" >&2
cat "$WORK_DIR/stderr-3.tmp" >&2
fail=1
fi
assert_contains "login override drives tea with the explicit login" "TEA: pr create --repo mosaicstack/stack --login alice"
assert_not_contains "login override must not hit the API" "CURL-URL"
# ---------------------------------------------------------------------------
# 4. Nothing set -> preserved behavior: tea path with the tea-list login.
# ---------------------------------------------------------------------------
: > "$LOG_FILE"
set +e
out=$(run_pr_create 2>"$WORK_DIR/stderr-4.tmp")
rc=$?
set -e
if [[ "$rc" -ne 0 ]]; then
echo "FAIL: default path — expected rc=0, got $rc" >&2
cat "$WORK_DIR/stderr-4.tmp" >&2
fail=1
fi
assert_contains "default path still uses the tea-list login" "TEA: pr create --repo mosaicstack/stack --login alice"
if [[ "$fail" -eq 0 ]]; then
echo "pr-create identity-first happy-path regression passed"
fi
exit "$fail"
@@ -0,0 +1,247 @@
#!/usr/bin/env bash
# Regression harness for pr-merge.sh identity-first principal resolution
# (mosaicstack/stack #1280).
#
# Covers:
# 1. --dry-run reports the acting principal the merge WOULD use, resolved the
# same way the real merge resolves it: --login > MOSAIC_GIT_IDENTITY /
# git config mosaic.gitIdentity > shared host credential. (The pre-#1280
# deployed copy reported a tea login that the merge would not act as.)
# 2. --dry-run fails closed when the requested principal has no credential:
# unknown --login, or an identity with no per-slot token (stderr names
# the login / the identity and its slot path).
# 3. The real merge POST carries the resolved principal's credential and no
# other: --login merges with that login's tea-config token; an identity
# merges with the per-slot token; an unresolvable --login never reaches
# the provider.
#
# Fixture pattern from test-pr-merge-head-pin.sh: the scripts under test are
# copied into a fixture tree with stubbed pr-metadata.sh / ci-queue-wait.sh
# siblings; the provider is a stubbed curl that records the credential it
# received. NEVER reads real secrets or hits a live forge.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-principal-resolution}"
FAKE_HOME="$WORK_DIR/home"
REPO_DIR="$WORK_DIR/repo"
TOOLS_DIR="$WORK_DIR/tools"
BIN_DIR="$WORK_DIR/bin"
LOG_FILE="$WORK_DIR/calls.log"
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
SHA=0123456789abcdef0123456789abcdef01234567
rm -rf "$WORK_DIR"
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" \
"$REPO_DIR" "$TOOLS_DIR/git" "$TOOLS_DIR/_lib" "$BIN_DIR"
cp "$SCRIPT_DIR/pr-merge.sh" "$TOOLS_DIR/git/pr-merge.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS_DIR/git/detect-platform.sh"
cp "$SCRIPT_DIR/../_lib/credentials.sh" "$TOOLS_DIR/_lib/credentials.sh"
chmod +x "$TOOLS_DIR/git/pr-merge.sh"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
# Stubbed siblings pr-merge.sh resolves relative to its own SCRIPT_DIR.
cat > "$TOOLS_DIR/git/pr-metadata.sh" <<SH
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"next","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"mosaicstack/stack","title":"Test PR","author":{"login":"contributor"}}'
SH
cat > "$TOOLS_DIR/git/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
exit 0
SH
chmod +x "$TOOLS_DIR/git/pr-metadata.sh" "$TOOLS_DIR/git/ci-queue-wait.sh"
cat > "$CREDENTIALS_FILE" <<'JSON'
{
"gitea": {
"mosaicstack": {
"url": "https://git.mosaicstack.dev",
"token": "shared-mosaicstack-token"
}
}
}
JSON
cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML'
logins:
- name: fred-ms
url: https://git.mosaicstack.dev
token: SECRET-fred-ms-tea-token
YAML
echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token"
: > "$LOG_FILE"
# Stubbed tea for login-list resolution only.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == "login list --output json" ]]; then
cat <<'JSON'
[
{"name":"fred-ms","url":"https://git.mosaicstack.dev","default":true}
]
JSON
exit 0
fi
exit 0
SH
chmod +x "$BIN_DIR/tea"
# Stubbed provider. pr-merge passes curl config on STDIN with -K -; the stub
# reads stdin, records the Authorization header it received, answers 200.
cat > "$BIN_DIR/curl" <<SH
#!/usr/bin/env bash
set -euo pipefail
url=""
out_file=""
stdin_config=""
if [[ ! -t 0 ]]; then
stdin_config="\$(cat || true)"
fi
while [[ \$# -gt 0 ]]; do
case "\$1" in
-o) out_file="\$2"; shift 2 ;;
-K|-w|--max-filesize|--max-time|--connect-timeout|-sS) shift 2 ;;
*) [[ -n "\$1" && "\$1" != -* && -z "\$url" ]] && url="\$1"
shift
;;
esac
done
auth="\$(printf '%s' "\$stdin_config" | grep -o 'Authorization: token [^"]*' || true)"
printf 'CURL-URL: %s\nCURL-AUTH: %s\n' "\$url" "\$auth" >> "$LOG_FILE"
[[ -n "\$out_file" ]] && printf '{}' > "\$out_file"
printf '200\n'
exit 0
SH
chmod +x "$BIN_DIR/curl"
fail=0
assert_contains_log() {
local desc="$1" needle="$2"
if ! grep -qF -- "$needle" "$LOG_FILE"; then
echo "FAIL: $desc — log does not contain '$needle':" >&2
cat "$LOG_FILE" >&2
fail=1
fi
}
assert_not_contains_log() {
local desc="$1" needle="$2"
if grep -qF -- "$needle" "$LOG_FILE"; then
echo "FAIL: $desc — log must not contain '$needle':" >&2
cat "$LOG_FILE" >&2
fail=1
fi
}
run_pr_merge() {
local extra_args="$1"; shift
(
cd "$REPO_DIR"
# shellcheck disable=SC2086 # extra_args is deliberately word-split wrapper args
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" "$@" \
bash "$TOOLS_DIR/git/pr-merge.sh" -n 42 $extra_args
)
}
# ---------------------------------------------------------------------------
# 1. --dry-run reports the resolved acting principal truthfully.
# ---------------------------------------------------------------------------
out=$(run_pr_merge "--dry-run" MOSAIC_GIT_IDENTITY=agentX)
if [[ "$out" != *"as git identity 'agentX' (per-slot credential)"* ]]; then
echo "FAIL: dry-run identity — principal not reported: $out" >&2
fail=1
fi
out=$(run_pr_merge "--dry-run --login fred-ms" MOSAIC_GIT_IDENTITY=agentX)
if [[ "$out" != *"as tea login 'fred-ms'"* ]]; then
echo "FAIL: dry-run login override — login not reported (must beat env identity): $out" >&2
fail=1
fi
out=$(run_pr_merge "--dry-run")
if [[ "$out" != *"as default host credential"* ]]; then
echo "FAIL: dry-run default — not reported: $out" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 2. --dry-run fails closed when the requested principal has no credential.
# ---------------------------------------------------------------------------
stderr_file="$WORK_DIR/stderr.tmp"
set +e
out=$(run_pr_merge "--dry-run --login ghost" 2>"$stderr_file")
rc=$?
set -e
if [[ "$rc" -eq 0 ]] || [[ "$(cat "$stderr_file")" != *"ghost"* ]]; then
echo "FAIL: dry-run unknown --login — expected fail-loud naming 'ghost', rc=$rc" >&2
cat "$stderr_file" >&2
fail=1
fi
: > "$stderr_file"
set +e
out=$(run_pr_merge "--dry-run" MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$stderr_file")
rc=$?
set -e
err=$(cat "$stderr_file")
if [[ "$rc" -eq 0 ]] || [[ "$err" != *"agentNoSlot"* ]] \
|| [[ "$err" != *"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token"* ]]; then
echo "FAIL: dry-run identity without slot — expected fail-loud naming identity + slot path, rc=$rc" >&2
echo "$err" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 3. The real merge POST carries the resolved principal's credential ONLY.
# ---------------------------------------------------------------------------
: > "$LOG_FILE"
set +e
out=$(run_pr_merge "--login fred-ms" MOSAIC_GIT_IDENTITY=agentX 2>"$stderr_file")
rc=$?
set -e
if [[ "$rc" -ne 0 ]]; then
echo "FAIL: merge with --login — expected rc=0, got $rc" >&2
cat "$stderr_file" >&2
fail=1
fi
assert_contains_log "merge --login uses the login token" "CURL-AUTH: Authorization: token SECRET-fred-ms-tea-token"
assert_not_contains_log "merge --login must not use the identity slot token" "SECRET-agentX-slot-token"
assert_not_contains_log "merge --login must not use the shared token" "shared-mosaicstack-token"
: > "$LOG_FILE"
set +e
out=$(run_pr_merge "" MOSAIC_GIT_IDENTITY=agentX 2>"$stderr_file")
rc=$?
set -e
if [[ "$rc" -ne 0 ]]; then
echo "FAIL: merge with identity — expected rc=0, got $rc" >&2
cat "$stderr_file" >&2
fail=1
fi
assert_contains_log "merge identity uses the per-slot token" "CURL-AUTH: Authorization: token SECRET-agentX-slot-token"
assert_not_contains_log "merge identity must not use the shared token" "shared-mosaicstack-token"
: > "$LOG_FILE"
set +e
out=$(run_pr_merge "--login ghost" 2>"$stderr_file")
rc=$?
set -e
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: merge with unknown --login — expected nonzero, got 0" >&2
fail=1
fi
assert_not_contains_log "merge with unknown --login must not reach the provider" "CURL-URL"
if [[ "$fail" -eq 0 ]]; then
echo "pr-merge identity-first principal resolution regression passed"
fi
exit "$fail"
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 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/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-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.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-pr-merge-queue-branch.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/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"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.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/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-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.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-pr-merge-queue-branch.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-gitea-principal-resolution.sh && bash framework/tools/git/test-pr-create-identity-first.sh && bash framework/tools/git/test-pr-merge-principal-resolution.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"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",