Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
e483d976e4 feat(#462): add federation list verb
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 20:46:19 -05:00
20 changed files with 22 additions and 2382 deletions

View File

@@ -1,5 +1,5 @@
# Build, publish npm packages, and push Docker images # Build, publish npm packages, and push Docker images
# Runs on main for stable publishes and on next for integration-line prereleases/images # Runs only on main branch push/tag
variables: variables:
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine + # Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
@@ -23,21 +23,9 @@ variables:
- 'docs/**' - 'docs/**'
- '**/*.md' - '**/*.md'
- '.woodpecker/**' - '.woodpecker/**'
- event: [push, manual]
branch: next
- &main_image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when: when:
- branch: [main, next] - branch: [main]
event: [push, manual, tag] event: [push, manual, tag]
steps: steps:
@@ -115,84 +103,6 @@ steps:
depends_on: depends_on:
- build - build
publish-next-npm:
image: *node_image
# Durable @next integration-line publish. Runs only on next; never writes
# the latest dist-tag and never commits the computed prerelease versions.
when:
- event: [push, manual]
branch: next
environment:
NPM_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
commands:
- *enable_pnpm
- |
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
exit 1
fi
if [ -z "$CI_PIPELINE_NUMBER" ]; then
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
exit 1
fi
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
const roots = ['apps', 'packages', 'plugins'];
const updated = [];
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const packagePath = path.join(fullPath, 'package.json');
if (fs.existsSync(packagePath)) updatePackage(packagePath);
walk(fullPath);
}
}
}
function updatePackage(packagePath) {
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
if (!stableMatch) {
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
}
const [, major, minor, patch] = stableMatch;
const oldVersion = manifest.version;
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
}
for (const root of roots) walk(root);
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
for (const line of updated) console.log('[publish-next] ' + line);
NODE
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
exit 1
fi
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
depends_on:
- build
# TODO: Uncomment when ready to publish to npmjs.org # TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs: # publish-npmjs:
# image: *node_image # image: *node_image
@@ -224,17 +134,8 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json - echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- | - |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}" DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "next" ]; then if [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next gateway publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
exit 1
fi fi
if [ -n "$CI_COMMIT_TAG" ]; then if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
@@ -245,7 +146,7 @@ steps:
build-appservice: build-appservice:
image: gcr.io/kaniko-project/executor:debug image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when when: *image_build_when
environment: environment:
REGISTRY_USER: REGISTRY_USER:
from_secret: gitea_username from_secret: gitea_username
@@ -271,7 +172,7 @@ steps:
build-web: build-web:
image: gcr.io/kaniko-project/executor:debug image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when when: *image_build_when
environment: environment:
REGISTRY_USER: REGISTRY_USER:
from_secret: gitea_username from_secret: gitea_username

View File

@@ -30,16 +30,6 @@ This installs both components:
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` | | **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` | | **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
### Install lanes
| Lane | Command | Use when | Source |
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
After install, the wizard runs automatically or you can invoke it manually: After install, the wizard runs automatically or you can invoke it manually:
```bash ```bash
@@ -346,9 +336,7 @@ The CLI also performs a background update check on every invocation (cached for
bash tools/install.sh --check # Version check only bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI) bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework) bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --next # Prerelease lane: source build from next bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
bash tools/install.sh --yes # Non-interactive, accept all defaults bash tools/install.sh --yes # Non-interactive, accept all defaults
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
``` ```

View File

@@ -1,519 +0,0 @@
/**
* Federation M3 single-gateway integration tests (FED-M3-10).
*
* Covers MILESTONES.md M3 acceptance:
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
* - #7: max_rows_per_query caps list results.
*
* Strategy:
* - Real PostgreSQL via @mosaicstack/db.
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
*
* Run:
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
* src/__tests__/integration/federation-m3-list.integration.test.ts
*/
import 'reflect-metadata';
import * as crypto from 'node:crypto';
import type { ExecutionContext } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import type { FastifyReply, FastifyRequest } from 'fastify';
import {
and,
createDb,
eq,
federationGrants,
federationPeers,
inArray,
missionTasks,
missions,
projects,
tasks,
teamMembers,
teams,
type Db,
type DbHandle,
users,
} from '@mosaicstack/db';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { DB } from '../../database/database.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
import { FederationScopeService } from '../../federation/server/scope.service.js';
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
import { ListController } from '../../federation/server/verbs/list.controller.js';
import {
makeMosaicIssuedCert,
makeSelfSignedCert,
} from '../../federation/__tests__/helpers/test-cert.js';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
interface TestIds {
readonly subjectUserId: string;
readonly otherUserId: string;
readonly peerId: string;
readonly revokedPeerId: string;
readonly activeGrantId: string;
readonly revokedGrantId: string;
readonly subjectProjectId: string;
readonly subjectMissionId: string;
readonly otherProjectId: string;
readonly teamId: string;
readonly unauthorizedTeamId: string;
readonly teamProjectId: string;
readonly taskIds: readonly string[];
readonly excludedTaskIds: readonly string[];
readonly subjectNoteId: string;
readonly otherUserNoteId: string;
}
function pemToDer(pem: string): Buffer {
return Buffer.from(
pem
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s+/g, ''),
'base64',
);
}
function makeFederationRequest(certPem: string): FastifyRequest {
return {
raw: {
socket: {
getPeerCertificate: () => ({
raw: pemToDer(certPem),
serialNumber: CERT_SERIAL_HEX,
}),
},
},
} as unknown as FastifyRequest;
}
function makeGuardContext(request: FastifyRequest): {
readonly context: ExecutionContext;
readonly sent: { statusCode?: number; payload?: unknown };
} {
const sent: { statusCode?: number; payload?: unknown } = {};
const reply = {
status: (statusCode: number) => {
sent.statusCode = statusCode;
return {
header: () => ({
send: (payload: unknown) => {
sent.payload = payload;
},
}),
};
},
} as unknown as FastifyReply;
const context = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => reply,
}),
} as unknown as ExecutionContext;
return { context, sent };
}
async function insertUser(db: Db, id: string, label: string): Promise<void> {
await db.insert(users).values({
id,
name: `${RUN_ID}-${label}`,
email: `${RUN_ID}-${label}@federation-test.invalid`,
emailVerified: false,
});
}
async function seedFixtures(db: Db): Promise<TestIds> {
const subjectUserId = `${RUN_ID}-subject`;
const otherUserId = `${RUN_ID}-other`;
const peerId = crypto.randomUUID();
const revokedPeerId = crypto.randomUUID();
const activeGrantId = crypto.randomUUID();
const revokedGrantId = crypto.randomUUID();
const subjectProjectId = crypto.randomUUID();
const subjectMissionId = crypto.randomUUID();
const otherProjectId = crypto.randomUUID();
const teamId = crypto.randomUUID();
const unauthorizedTeamId = crypto.randomUUID();
const teamProjectId = crypto.randomUUID();
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
const subjectNoteId = crypto.randomUUID();
const otherUserNoteId = crypto.randomUUID();
await insertUser(db, subjectUserId, 'subject');
await insertUser(db, otherUserId, 'other');
await db.insert(teams).values([
{
id: teamId,
name: `${RUN_ID} allowed team`,
slug: `${RUN_ID}-allowed-team`,
ownerId: subjectUserId,
managerId: subjectUserId,
},
{
id: unauthorizedTeamId,
name: `${RUN_ID} unauthorized team`,
slug: `${RUN_ID}-unauthorized-team`,
ownerId: otherUserId,
managerId: otherUserId,
},
]);
await db.insert(teamMembers).values([
{ teamId, userId: subjectUserId, role: 'member' },
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
]);
await db.insert(projects).values([
{
id: subjectProjectId,
name: `${RUN_ID} subject personal project`,
ownerType: 'user',
ownerId: subjectUserId,
},
{
id: otherProjectId,
name: `${RUN_ID} other personal project`,
ownerType: 'user',
ownerId: otherUserId,
},
{
id: teamProjectId,
name: `${RUN_ID} unauthorized team project`,
ownerType: 'team',
teamId: unauthorizedTeamId,
},
]);
await db.insert(missions).values({
id: subjectMissionId,
name: `${RUN_ID} subject mission`,
projectId: subjectProjectId,
userId: subjectUserId,
});
await db.insert(tasks).values([
{
id: taskIds[0],
title: `${RUN_ID} visible task 1`,
missionId: subjectMissionId,
createdAt: new Date('2026-06-25T03:00:00.000Z'),
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
},
{
id: taskIds[1],
title: `${RUN_ID} visible task 2`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T02:00:00.000Z'),
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
},
{
id: taskIds[2],
title: `${RUN_ID} visible task 3`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T01:00:00.000Z'),
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
},
{
id: excludedTaskIds[0],
title: `${RUN_ID} other user task`,
projectId: otherProjectId,
createdAt: new Date('2026-06-25T04:00:00.000Z'),
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
},
{
id: excludedTaskIds[1],
title: `${RUN_ID} unauthorized team task`,
projectId: teamProjectId,
createdAt: new Date('2026-06-25T05:00:00.000Z'),
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
},
]);
await db.insert(missionTasks).values([
{
id: subjectNoteId,
missionId: subjectMissionId,
userId: subjectUserId,
notes: `${RUN_ID} subject visible note`,
createdAt: new Date('2026-06-25T03:30:00.000Z'),
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
},
{
id: otherUserNoteId,
missionId: subjectMissionId,
userId: otherUserId,
notes: `${RUN_ID} other user note on subject mission`,
createdAt: new Date('2026-06-25T04:30:00.000Z'),
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
},
]);
await db.insert(federationPeers).values([
{
id: peerId,
commonName: `${RUN_ID}-active-peer`,
displayName: `${RUN_ID} Active Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: CERT_SERIAL_HEX,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
{
id: revokedPeerId,
commonName: `${RUN_ID}-revoked-peer`,
displayName: `${RUN_ID} Revoked Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
]);
await db.insert(federationGrants).values([
{
id: activeGrantId,
peerId,
subjectUserId,
status: 'active',
scope: {
resources: ['tasks', 'notes'],
excluded_resources: [],
filters: {
tasks: { include_personal: true, include_teams: [] },
notes: { include_personal: true, include_teams: [] },
},
max_rows_per_query: 2,
},
},
{
id: revokedGrantId,
peerId,
subjectUserId,
status: 'revoked',
revokedAt: new Date(),
revokedReason: `${RUN_ID} revoked grant fixture`,
scope: {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 2,
},
},
]);
return {
subjectUserId,
otherUserId,
peerId,
revokedPeerId,
activeGrantId,
revokedGrantId,
subjectProjectId,
subjectMissionId,
otherProjectId,
teamId,
unauthorizedTeamId,
teamProjectId,
taskIds,
excludedTaskIds,
subjectNoteId,
otherUserNoteId,
};
}
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
if (!ids) {
return;
}
await db
.delete(missionTasks)
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
.catch(() => {});
await db
.delete(tasks)
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
.catch(() => {});
await db
.delete(missions)
.where(eq(missions.id, ids.subjectMissionId))
.catch(() => {});
await db
.delete(projects)
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
.catch(() => {});
await db
.delete(teamMembers)
.where(
and(
eq(teamMembers.userId, ids.subjectUserId),
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
),
)
.catch(() => {});
await db
.delete(teams)
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
.catch(() => {});
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
.catch(() => {});
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
.catch(() => {});
await db
.delete(users)
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
.catch(() => {});
}
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
let handle: DbHandle;
let db: Db;
let moduleRef: TestingModule;
let guard: FederationAuthGuard;
let listController: ListController;
let ids: TestIds | undefined;
beforeAll(async () => {
handle = createDb(PG_URL);
db = handle.db;
ids = await seedFixtures(db);
moduleRef = await Test.createTestingModule({
controllers: [ListController],
providers: [
{ provide: DB, useValue: db },
GrantsService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
}).compile();
guard = moduleRef.get(FederationAuthGuard);
listController = moduleRef.get(ListController);
}, 30_000);
afterAll(async () => {
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
});
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
const malformedOidCert = await makeSelfSignedCert();
const request = makeFederationRequest(malformedOidCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(401);
expect(sent.payload).toMatchObject({
error: {
code: 'unauthorized',
message: expect.stringContaining('missing required OID'),
},
});
expect(request.federationContext).toBeUndefined();
});
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
expect(ids).toBeDefined();
const revokedCert = await makeMosaicIssuedCert({
grantId: ids!.revokedGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(revokedCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(403);
expect(sent.payload).toMatchObject({
error: {
code: 'forbidden',
message: 'Federation access denied',
},
});
expect(request.federationContext).toBeUndefined();
});
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('tasks', request, { limit: 100 });
const returnedIds = response.items.map((item) => item['id']);
expect(response.items).toHaveLength(2);
expect(response._truncated).toBe(true);
expect(response.nextCursor).toEqual(expect.any(String));
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
expect(returnedIds).not.toContain(ids!.taskIds[2]);
for (const excludedId of ids!.excludedTaskIds) {
expect(returnedIds).not.toContain(excludedId);
}
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('excludes another user mission task notes on the same authorized mission', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('notes', request, { limit: 10 });
const returnedIds = response.items.map((item) => item['id']);
expect(returnedIds).toEqual([ids!.subjectNoteId]);
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('fails closed for unsupported list resources', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is not supported',
},
},
status: 403,
});
});
});

View File

@@ -5,8 +5,6 @@ import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentService } from './enrollment.service.js'; import { EnrollmentService } from './enrollment.service.js';
import { FederationController } from './federation.controller.js'; import { FederationController } from './federation.controller.js';
import { CapabilitiesController } from './server/verbs/capabilities.controller.js'; import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
import { GetController } from './server/verbs/get.controller.js';
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
import { GrantsService } from './grants.service.js'; import { GrantsService } from './grants.service.js';
import { FederationClientService, QuerySourceService } from './client/index.js'; import { FederationClientService, QuerySourceService } from './client/index.js';
import { FederationAuthGuard, FederationScopeService } from './server/index.js'; import { FederationAuthGuard, FederationScopeService } from './server/index.js';
@@ -14,13 +12,7 @@ import { ListController } from './server/verbs/list.controller.js';
import { FederationListQueryService } from './server/verbs/list-query.service.js'; import { FederationListQueryService } from './server/verbs/list-query.service.js';
@Module({ @Module({
controllers: [ controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
EnrollmentController,
FederationController,
CapabilitiesController,
ListController,
GetController,
],
providers: [ providers: [
AdminGuard, AdminGuard,
CaService, CaService,
@@ -31,7 +23,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard, FederationAuthGuard,
FederationScopeService, FederationScopeService,
FederationListQueryService, FederationListQueryService,
FederationGetQueryService,
], ],
exports: [ exports: [
CaService, CaService,
@@ -42,7 +33,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard, FederationAuthGuard,
FederationScopeService, FederationScopeService,
FederationListQueryService, FederationListQueryService,
FederationGetQueryService,
], ],
}) })
export class FederationModule {} export class FederationModule {}

View File

@@ -1,348 +0,0 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
createPgliteDb,
missionTasks,
missions,
projects,
runPgliteMigrations,
teams,
users,
type Db,
type DbHandle,
} from '@mosaicstack/db';
import type { FederationScopeQueryFilter } from '../../scope.service.js';
import { FederationGetQueryService } from '../get-query.service.js';
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
resource: 'credentials',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: [],
limit: 1,
maxRowsPerQuery: 25,
};
const SUBJECT_USER_ID = 'fed-m3-06-subject';
const OTHER_USER_ID = 'fed-m3-06-other';
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
let dbHandle: DbHandle | undefined;
function makeService() {
return new FederationGetQueryService({} as Db);
}
function makeDbService() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
return new FederationGetQueryService(dbHandle.db);
}
async function seedNotesFixture() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
await dbHandle.db.insert(users).values([
{
id: SUBJECT_USER_ID,
name: 'Federation Subject',
email: `${SUBJECT_USER_ID}@example.test`,
emailVerified: false,
},
{
id: OTHER_USER_ID,
name: 'Federation Other',
email: `${OTHER_USER_ID}@example.test`,
emailVerified: false,
},
]);
await dbHandle.db.insert(teams).values([
{
id: TEAM_ID,
name: 'FED-M3-06 Team',
slug: 'fed-m3-06-team',
ownerId: SUBJECT_USER_ID,
managerId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_TEAM_ID,
name: 'FED-M3-06 Unauthorized Team',
slug: 'fed-m3-06-unauthorized-team',
ownerId: OTHER_USER_ID,
managerId: OTHER_USER_ID,
},
]);
await dbHandle.db.insert(projects).values([
{
id: PERSONAL_PROJECT_ID,
name: 'FED-M3-06 Personal Project',
ownerId: SUBJECT_USER_ID,
ownerType: 'user',
},
{
id: TEAM_PROJECT_ID,
name: 'FED-M3-06 Team Project',
teamId: TEAM_ID,
ownerType: 'team',
},
{
id: UNAUTHORIZED_PROJECT_ID,
name: 'FED-M3-06 Unauthorized Project',
teamId: UNAUTHORIZED_TEAM_ID,
ownerType: 'team',
},
]);
await dbHandle.db.insert(missions).values([
{
id: PERSONAL_MISSION_ID,
name: 'FED-M3-06 Personal Mission',
projectId: PERSONAL_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: TEAM_MISSION_ID,
name: 'FED-M3-06 Team Mission',
projectId: TEAM_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_MISSION_ID,
name: 'FED-M3-06 Unauthorized Mission',
projectId: UNAUTHORIZED_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
]);
await dbHandle.db.insert(missionTasks).values([
{
id: SUBJECT_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on team mission',
createdAt: new Date('2026-06-24T03:00:00.000Z'),
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
},
{
id: OTHER_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: OTHER_USER_ID,
notes: 'other user note on team mission',
createdAt: new Date('2026-06-24T02:00:00.000Z'),
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
},
{
id: SUBJECT_PERSONAL_NOTE_ID,
missionId: PERSONAL_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on personal mission',
createdAt: new Date('2026-06-24T01:00:00.000Z'),
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
},
{
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
missionId: UNAUTHORIZED_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note outside grant-visible missions',
createdAt: new Date('2026-06-24T04:00:00.000Z'),
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
},
]);
}
describe('FederationGetQueryService', () => {
beforeAll(async () => {
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
await runPgliteMigrations(dbHandle);
await seedNotesFixture();
});
afterAll(async () => {
await dbHandle?.close();
dbHandle = undefined;
});
it('denies sensitive resources in native RBAC for M3 get reads', async () => {
const service = makeService();
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'credentials',
}),
).resolves.toMatchObject({
allowed: false,
reason: 'credentials federation get access is not implemented in M3',
});
});
it('allows personal memory reads without requiring team lookup', async () => {
const service = makeService();
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'memory',
}),
).resolves.toEqual({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
});
it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
const service = makeService();
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
(
service as unknown as {
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
}
).listSubjectTeamIds = listSubjectTeamIds;
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'tasks',
}),
).resolves.toEqual({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
});
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
});
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
const service = makeService();
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
status: 'denied',
reason: 'credentials federation get is not implemented',
});
});
it('fails closed for unsupported resources instead of returning undefined', async () => {
const service = makeService();
await expect(
service.get({
filter: {
...CREDENTIAL_FILTER,
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
},
id: 'row-1',
}),
).resolves.toEqual({
status: 'denied',
reason: 'Unsupported federation get resource: unknown-resource',
});
});
it('does not leak another user mission task note through team-scoped get reads', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: OTHER_TEAM_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
it('does not return subject notes from missions outside the grant-visible project set', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: true,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_TEAM_NOTE_ID,
}),
).resolves.toMatchObject({
status: 'found',
item: {
id: SUBJECT_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
content: 'subject note on team mission',
},
});
});
it('does not return subject personal notes when includePersonal is false', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_PERSONAL_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
});

View File

@@ -1,207 +0,0 @@
import 'reflect-metadata';
import { RequestMethod } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FederationAuthGuard } from '../../federation-auth.guard.js';
import type {
FederationScopeEvaluationResult,
FederationScopeQueryFilter,
} from '../../scope.service.js';
import { GetController } from '../get.controller.js';
import type { FederationGetQueryResult } from '../get-query.service.js';
const FEDERATION_CONTEXT = {
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
scope: { resources: ['tasks'], max_rows_per_query: 25 },
};
const TASK_FILTER: FederationScopeQueryFilter = {
resource: 'tasks',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: ['team-1'],
limit: 1,
maxRowsPerQuery: 25,
};
function makeRequest(): FastifyRequest {
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
}
function allowedScope(
filter: FederationScopeQueryFilter = TASK_FILTER,
): FederationScopeEvaluationResult {
return { allowed: true, filter };
}
function makeController(opts?: {
scopeResult?: FederationScopeEvaluationResult;
queryResult?: FederationGetQueryResult;
}) {
const scope = {
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
};
const query = {
evaluateReadAccess: vi.fn(),
get: vi.fn().mockResolvedValue(
opts?.queryResult ?? {
status: 'found',
item: {
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
},
},
),
};
return {
controller: new GetController(scope as never, query as never),
scope,
query,
};
}
describe('GetController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
});
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
const { controller, scope, query } = makeController();
const response = await controller.get('tasks', 'task-1', makeRequest());
expect(scope.evaluateAccess).toHaveBeenCalledWith({
context: FEDERATION_CONTEXT,
resource: 'tasks',
requestedLimit: 1,
nativeRbac: query,
});
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
expect(response).toEqual({
item: {
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
_source: 'local',
},
});
});
it('returns a federation error envelope when auth guard context is missing', async () => {
const { controller, scope, query } = makeController();
await expect(
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
).rejects.toMatchObject({
response: {
error: {
code: 'unauthorized',
message: 'Federation context missing',
},
},
status: 401,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.get).not.toHaveBeenCalled();
});
it('returns a federation error envelope when scope evaluation denies access', async () => {
const { controller, query } = makeController({
scopeResult: {
allowed: false,
deny: {
code: 'resource_excluded',
stage: 'resource_exclusion',
statusCode: 403,
message: 'Requested federation resource is explicitly excluded by grant scope',
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'credentials',
},
},
});
await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is explicitly excluded by grant scope',
},
},
status: 403,
});
expect(query.get).not.toHaveBeenCalled();
});
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
const { controller } = makeController({ queryResult: { status: 'not_found' } });
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
response: { error: { code: 'not_found' } },
status: 404,
});
});
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
const { controller } = makeController({
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
});
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Task is outside the federated scope',
},
},
status: 403,
});
});
it('fails closed when the query layer denies an unsupported resource', async () => {
const unsupportedFilter: FederationScopeQueryFilter = {
...TASK_FILTER,
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
};
const { controller } = makeController({
scopeResult: allowedScope(unsupportedFilter),
queryResult: {
status: 'denied',
reason: 'Unsupported federation get resource: unknown-resource',
},
});
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Unsupported federation get resource: unknown-resource',
},
},
status: 403,
});
});
it('rejects empty ids before evaluating scope', async () => {
const { controller, scope, query } = makeController();
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.get).not.toHaveBeenCalled();
});
});

View File

@@ -1,311 +0,0 @@
/**
* Federation get query layer (FED-M3-06).
*
* Read-only DB adapter used by GetController after FederationAuthGuard and
* FederationScopeService have established the subject user, allowed resource,
* native-RBAC intersection, and row cap. Audit writes are intentionally
* deferred to M4.
*/
import { Inject, Injectable } from '@nestjs/common';
import {
and,
eq,
inArray,
insights,
or,
missionTasks,
missions,
preferences,
projects,
tasks,
teamMembers,
type Db,
} from '@mosaicstack/db';
import { DB } from '../../../database/database.module.js';
import type {
FederationNativeRbacEvaluator,
FederationNativeRbacRequest,
FederationNativeRbacResult,
FederationScopeQueryFilter,
} from '../scope.service.js';
export interface FederationGetQueryRequest {
readonly filter: FederationScopeQueryFilter;
readonly id: string;
}
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
readonly status: 'found';
readonly item: T;
}
export interface FederationGetQueryNotFoundResult {
readonly status: 'not_found';
}
export interface FederationGetQueryDeniedResult {
readonly status: 'denied';
readonly reason: string;
}
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
| FederationGetQueryFoundResult<T>
| FederationGetQueryNotFoundResult
| FederationGetQueryDeniedResult;
type RowObject = Record<string, unknown>;
function firstRow<T>(rows: T[]): T | undefined {
return rows[0];
}
function rowBelongsToAccessibleProjectOrMission(
row: { projectId?: string | null; missionId?: string | null },
projectIds: readonly string[],
missionIds: readonly string[],
): boolean {
return (
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
);
}
@Injectable()
export class FederationGetQueryService implements FederationNativeRbacEvaluator {
constructor(@Inject(DB) private readonly db: Db) {}
async evaluateReadAccess(
request: FederationNativeRbacRequest,
): Promise<FederationNativeRbacResult> {
if (request.resource === 'credentials' || request.resource === 'api_keys') {
return {
allowed: false,
reason: `${request.resource} federation get access is not implemented in M3`,
details: { resource: request.resource },
};
}
if (request.resource === 'memory') {
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
}
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
return { allowed: true, access: { includePersonal: true, teamIds } };
}
async get<T extends RowObject = RowObject>(
request: FederationGetQueryRequest,
): Promise<FederationGetQueryResult<T>> {
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
}
private async getByResource(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
switch (filter.resource) {
case 'tasks':
return this.getTask(filter, id);
case 'notes':
return this.getNote(filter, id);
case 'memory':
return this.getMemory(filter, id);
case 'credentials':
case 'api_keys':
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
default:
return {
status: 'denied',
reason: `Unsupported federation get resource: ${String(filter.resource)}`,
};
}
}
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
const rows = await this.db
.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.userId, subjectUserId));
return rows.map((row) => row.teamId);
}
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
const clauses = [];
if (filter.includePersonal) {
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
}
if (filter.teamIds.length > 0) {
// Project team ownership follows TeamsService.canAccessProject: team-owned
// rows are authorized through projects.teamId, while ownerId remains the
// user who created/bootstrapped the project.
clauses.push(
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
);
}
if (clauses.length === 0) {
return [];
}
const rows = await this.db
.select({ id: projects.id })
.from(projects)
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
return rows.map((row) => row.id);
}
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
if (projectIds.length === 0) {
return [];
}
const rows = await this.db
.select({ id: missions.id })
.from(missions)
.where(inArray(missions.projectId, [...projectIds]));
return rows.map((row) => row.id);
}
private async getTask(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const row = firstRow(
await this.db
.select({
id: tasks.id,
title: tasks.title,
description: tasks.description,
status: tasks.status,
priority: tasks.priority,
projectId: tasks.projectId,
missionId: tasks.missionId,
assignee: tasks.assignee,
tags: tasks.tags,
dueDate: tasks.dueDate,
metadata: tasks.metadata,
createdAt: tasks.createdAt,
updatedAt: tasks.updatedAt,
})
.from(tasks)
.where(eq(tasks.id, id))
.limit(1),
);
if (!row) {
return { status: 'not_found' };
}
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
return { status: 'denied', reason: 'Task is outside the federated scope' };
}
return { status: 'found', item: row as RowObject };
}
private async getNote(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const row = firstRow(
await this.db
.select({
id: missionTasks.id,
missionId: missionTasks.missionId,
taskId: missionTasks.taskId,
userId: missionTasks.userId,
status: missionTasks.status,
content: missionTasks.notes,
createdAt: missionTasks.createdAt,
updatedAt: missionTasks.updatedAt,
})
.from(missionTasks)
.where(eq(missionTasks.id, id))
.limit(1),
);
if (!row || row.content === null || row.content === '') {
return { status: 'not_found' };
}
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
// mission_tasks rows are user-scoped even when the mission belongs to a team.
// Scope-visible missions must intersect with subject ownership; team scope
// narrows mission IDs but never widens note reads to another user's rows.
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
return { status: 'denied', reason: 'Note is outside the federated scope' };
}
const item = { ...row } as RowObject;
delete item['userId'];
return { status: 'found', item };
}
private async getMemory(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const [insightRow, preferenceRow] = await Promise.all([
this.db
.select({
id: insights.id,
userId: insights.userId,
kind: insights.source,
content: insights.content,
category: insights.category,
relevanceScore: insights.relevanceScore,
metadata: insights.metadata,
createdAt: insights.createdAt,
updatedAt: insights.updatedAt,
})
.from(insights)
.where(eq(insights.id, id))
.limit(1)
.then(firstRow),
this.db
.select({
id: preferences.id,
userId: preferences.userId,
kind: preferences.category,
key: preferences.key,
value: preferences.value,
source: preferences.source,
mutable: preferences.mutable,
createdAt: preferences.createdAt,
updatedAt: preferences.updatedAt,
})
.from(preferences)
.where(eq(preferences.id, id))
.limit(1)
.then(firstRow),
]);
const candidates = [insightRow, preferenceRow].filter(
(row): row is NonNullable<typeof row> => row !== undefined,
);
if (candidates.length === 0) {
return { status: 'not_found' };
}
if (!filter.includePersonal) {
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
}
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
if (!accessible) {
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
}
const item = { ...accessible } as RowObject;
delete item['userId'];
return { status: 'found', item };
}
}

View File

@@ -1,100 +0,0 @@
/**
* Federation get verb (FED-M3-06).
*
* POST /api/federation/v1/get/:resource/:id
*
* Pipeline: FederationAuthGuard attaches the active grant context, then
* FederationScopeService enforces grant scope + native RBAC intersection, then
* the read-only query layer fetches one local row and tags it with `_source`.
* Read audit-log writes are deferred to M4; this controller does not persist
* request or response bodies.
*/
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import {
FederationInvalidRequestError,
FederationNotFoundError,
FederationScopeViolationError,
FederationUnauthorizedError,
SOURCE_LOCAL,
type FederationGetResponse,
type SourceTag,
} from '@mosaicstack/types';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import '../federation-context.js';
import { FederationScopeService } from '../scope.service.js';
import { FederationGetQueryService } from './get-query.service.js';
type FederatedRow = Record<string, unknown> & SourceTag;
function scopeDenyToHttpException(deny: {
readonly statusCode: 400 | 403;
readonly message: string;
}): HttpException {
const ErrorClass =
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
}
@Controller('api/federation/v1/get')
@UseGuards(FederationAuthGuard)
export class GetController {
constructor(
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
) {}
@Post(':resource/:id')
async get(
@Param('resource') resource: string,
@Param('id') id: string,
@Req() request: FastifyRequest,
): Promise<FederationGetResponse<FederatedRow>> {
if (!request.federationContext) {
throw new HttpException(
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
401,
);
}
if (id.trim().length === 0) {
throw new HttpException(
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
400,
);
}
const scopeResult = await this.scope.evaluateAccess({
context: request.federationContext,
resource,
requestedLimit: 1,
nativeRbac: this.query,
});
if (!scopeResult.allowed) {
throw scopeDenyToHttpException(scopeResult.deny);
}
const result = await this.query.get({ filter: scopeResult.filter, id });
if (result.status === 'not_found') {
throw new HttpException(
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
404,
);
}
if (result.status === 'denied') {
throw new HttpException(
new FederationScopeViolationError(result.reason, {
resource,
id,
grantId: request.federationContext.grantId,
peerId: request.federationContext.peerId,
subjectUserId: request.federationContext.subjectUserId,
}).toEnvelope(),
403,
);
}
return { item: { ...result.item, _source: SOURCE_LOCAL } };
}
}

View File

@@ -1,63 +0,0 @@
# npm `@next` prerelease lane
Status: **IMPLEMENTED**
## Current behavior
`tools/install.sh --next` provides the prerelease integration lane for the permanent `next` branch.
The lane is fast-by-default:
1. Install framework files from the `next` source archive.
2. Resolve the Gitea npm registry `next` dist-tag for the globally installed packages:
```bash
npm view @mosaicstack/gateway@next version
npm view @mosaicstack/mosaic@next version
```
3. Require both resolved versions to share the same `next.<pipeline>` suffix, then install the exact resolved versions.
4. If either `@next` package is missing, unreachable, mismatched, or fails to install, fall back to the source-build path at `next`.
`--next` never hard-fails solely because the prerelease npm dist-tag is unavailable.
## Published packages
The `next` publish pipeline publishes non-private `@mosaicstack/*` packages to the Mosaic Gitea npm registry:
```text
https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
```
Observed `next` dist-tags after enabling the pipeline:
```text
@mosaicstack/mosaic@next -> 0.0.49-next.1633
@mosaicstack/gateway@next -> 0.0.7-next.1633
```
The gateway also publishes a Docker image as `gateway:sha-<short>` on `next` merges. The installer fast path uses the npm gateway package when available; the Docker image is for deployed gateway/runtime harness flows.
## Explicit source lanes
Source builds remain available and are still the authority for explicit ref validation:
- `--dev` always builds from source.
- `--ref <ref>` / `MOSAIC_REF=<ref>` wins over `--next` and uses the source path for that exact ref.
## Pipeline shape
1. Trigger on `next` merges.
2. Compute the next prerelease version from the upcoming stable version plus the Woodpecker pipeline number (`<target-stable>-next.<CI_PIPELINE_NUMBER>`).
3. Build and publish non-private packages in CI.
4. Publish to the Mosaic Gitea npm registry with dist-tag `next`.
5. Keep `latest` untouched; only main/release promotion can update `latest`.
6. Publish gateway Docker images from `next` as `gateway:sha-<short>` only.
## Guardrails
- `@next` is mutable prerelease convenience, not a deployment pin.
- Stable installs continue to use `@latest`.
- Contributor validation remains available through `--dev --ref <branch>`.
- Pipeline output traces every prerelease package back to the source commit on `next`.
- The installer falls back to source rather than hard-failing on prerelease registry issues.

View File

@@ -211,17 +211,6 @@ pnpm format:check && pnpm typecheck && pnpm lint
A pre-push hook enforces this mechanically. A pre-push hook enforces this mechanically.
### CI Publish Channels
Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts separate:
| Source | npm packages | Gateway image |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `main` push/manual or release tag | committed package versions published to Gitea npm without changing the dist-tag workflow | `gateway:sha-<short>` plus `gateway:latest` on `main`, and the release tag on tag events |
| `next` push/manual | CI-computed prereleases, `<target-stable>-next.<CI_PIPELINE_NUMBER>`, published with `npm publish --tag next` | `gateway:sha-<short>` only |
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
--- ---
## Adding New Agent Tools ## Adding New Agent Tools

View File

@@ -175,18 +175,8 @@ Or use the direct URL:
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh) bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
``` ```
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
non-interactive use:
Install lanes:
| Lane | Command | Source |
| ------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | npm `@mosaicstack/mosaic@latest` + `main` |
| Prerelease integration | `bash tools/install.sh --next` | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | Build-from-source at the requested ref |
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` still wins and uses the source path.
Flags for non-interactive use:
```bash ```bash
--yes # Accept all defaults --yes # Accept all defaults

View File

@@ -1,38 +0,0 @@
# Scratchpad — FED-M3-06 get verb
## Objective
Implement `POST /api/federation/v1/get/:resource/:id` for M3 inbound federation reads.
## Scope
- `apps/gateway/src/federation/server/verbs/get.controller.ts`
- `apps/gateway/src/federation/server/verbs/get-query.service.ts`
- Unit coverage for controller pipeline + query service RBAC guardrails
- Register controller/service in `FederationModule`
## Plan
1. Mirror the list verb pipeline: `FederationAuthGuard``FederationScopeService` → read-only query service.
2. Return one `_source: "local"` tagged item on success.
3. Return federation error envelopes:
- `404 not_found` when the resource id does not exist.
- `403 scope_violation` when the row exists but falls outside native RBAC/scope intersection.
- `400 invalid_request` for malformed ids/scope requests.
4. Keep read audit persistence deferred to M4; no body or response persistence in M3.
## Verification Evidence
- Rebased onto `origin/main` at `86e106fcc9a1dfa3a18f7846bb477be128794aad` after M3-05 merged; resolved `FederationModule` by registering both list and get verb controllers/services.
- Review-change coverage added for comment 15971:
- get note access now requires subject ownership AND authorized mission intersection.
- missing federation context returns structured `401 unauthorized` envelope.
- unsupported get resources fail closed with structured denial.
- PGlite regressions cover cross-user note exclusion and subject-note unauthorized-mission exclusion.
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/verbs/__tests__/get.controller.spec.ts src/federation/server/verbs/__tests__/get-query.service.spec.ts` — pass (2 files / 17 tests; re-run after review changes).
- `pnpm --filter @mosaicstack/gateway build` — pass (re-run after review changes).
- `pnpm build` — pass (23 successful tasks before review changes).
- `pnpm typecheck` — pass (41 successful tasks; re-run after review changes).
- `pnpm lint` — pass (23 successful tasks; re-run after review changes).
- `pnpm format:check` — pass (re-run after review changes).
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings after review changes.

View File

@@ -1,82 +0,0 @@
# B1 / @next Durable Publish Pipeline — Design
## Objective
Make `next` a durable integration line that publishes the artifacts required by downstream federation boot tests without manual builds.
Every merge to `next` publishes:
1. **npm prerelease packages** to the Gitea npm registry with dist-tag `next`.
2. **Gateway container image** tagged only as `gateway:sha-<short>`.
The existing stable release behavior remains isolated to `main` / tags.
## Registry verification
Target registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`.
Pre-implementation checks:
- `npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json` returned a dist-tags object (`latest: 0.0.48`).
- `npm view @mosaicstack/mosaic@latest version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/` resolved `0.0.48`.
- `@next` currently returns 404 because no `next` dist-tag exists yet; this is expected before the first next prerelease publish.
Pipeline design includes a post-publish verification that `npm view @mosaicstack/mosaic@next version` resolves to the exact CI-computed prerelease version. If Gitea fails to honor the `next` dist-tag, the pipeline fails closed.
## Version scheme
The prerelease version is computed at publish time only; no `package.json` version changes are committed.
For each non-private `@mosaicstack/*` package:
```text
<target-stable>-next.<CI_PIPELINE_NUMBER>
```
Where:
- `CI_PIPELINE_NUMBER` is Woodpecker's monotonic pipeline number.
- `target-stable` is the package's current committed stable version with the patch component incremented.
- Example: `@mosaicstack/mosaic` `0.0.48` publishes as `0.0.49-next.1626`.
- Example: `@mosaicstack/gateway` `0.0.6` publishes as `0.0.7-next.1626`.
Rationale:
- npm semver sorts `0.0.49-next.1627` above `0.0.49-next.1626`.
- The prerelease does not overtake the future stable `0.0.49`.
- The monotonic pipeline number avoids conflicts across repeated `next` merges.
## Branch and tag guardrails
| Pipeline path | Branch/event | Publishes | Forbidden |
| --------------------- | ------------------------------ | ------------------------------------------------------- | ---------------------- |
| stable npm publish | `main` push/manual or tag | package versions already committed in package manifests | `@next` dist-tag |
| next npm publish | `next` push/manual only | CI-computed prereleases with `--tag next` | `latest` dist-tag |
| gateway image | `main` push/manual or tag | `sha-<short>` + `latest` on main + tag on tag events | next prerelease npm |
| gateway image | `next` push/manual only | `sha-<short>` only | `latest` |
| appservice/web images | `main` push/manual or tag only | existing stable image behavior | next image publication |
The pipeline has explicit branch checks inside the publish commands as a second fail-closed layer beyond Woodpecker `when` clauses.
## Implementation plan
1. Widen `.woodpecker/publish.yml` top-level `when` to include `next` so the publish pipeline runs on next merges.
2. Keep existing `publish-npm` on `main` / tags only.
3. Add `publish-next-npm` for `next` push/manual only:
- configure Gitea npm auth from existing `gitea_token` secret as `NPM_TOKEN`;
- preflight registry dist-tag metadata;
- compute prerelease versions in CI by temporarily editing package manifests in the workspace;
- run `pnpm publish ... --tag next` against non-private `@mosaicstack/*` packages;
- verify `@mosaicstack/mosaic@next` resolves to the computed version.
4. Split image `when` anchors:
- `image_build_when` includes `next` and is used by `build-gateway`;
- `main_image_build_when` keeps appservice/web on main/tags only.
5. Keep gateway next image destinations to `sha-<short>` only; no `latest` on next.
## Risk controls
- Auth/registry failures are fatal.
- No manual image build/push path is introduced.
- No production `latest` tags are touched from `next`.
- No `@latest` npm dist-tags are touched from `next`.
- All changes live in CI config and docs; no runtime source behavior changes.

View File

@@ -1,60 +0,0 @@
# FED-M3-10 — Federation M3 Integration Tests
## Objective
Add single-gateway gateway integration tests for M3 acceptance #6 and #7.
## Branch / base
- Branch: `feat/federation-m3-integration`
- Base: `origin/next` (`838701bd` after M3-06/#683 merge)
- PR base when unblocked: `next`
## Scope
- Real PostgreSQL via `@mosaicstack/db`.
- Mocked TLS context / Fastify request shim for `FederationAuthGuard`.
- Direct controller calls using the real M3 route contract: `POST /api/federation/v1/list/:resource` with body `{ limit?, cursor? }`.
- Gated by `FEDERATED_INTEGRATION=1`.
- No federation harness dependency.
## Fixture notes
Aligned with the B2 seed design vocabulary:
- `tasks` visibility uses personal `projects` + `missions` chain.
- `notes` are `mission_tasks.notes`; the integration suite asserts subject-only note visibility on an authorized mission.
- Seed includes a second user and unauthorized team/project tasks to prove exclusion from the max-row-cap list result.
- Grants/peers are direct DB fixtures; cert auth still runs through `FederationAuthGuard` using real X.509 certs generated by existing test helpers.
## Current implementation
Added `apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts` covering:
1. M3 #6 — cert missing Mosaic OIDs returns 401 federation `unauthorized` envelope.
2. M3 #6 — valid cert whose grant row is `revoked` returns 403 federation `forbidden` envelope.
3. M3 #7 — active grant with `max_rows_per_query: 2` caps `list tasks`, returns `_truncated` + `nextCursor`, source-tags rows, and excludes other-user / unauthorized-team tasks.
4. Cross-user notes invariant — subject can list their own `mission_tasks.notes` row while another user's note on the same authorized mission is excluded.
5. Unsupported-resource invariant — `list widgets` fails closed with a federation `scope_violation` envelope.
## Verification
- `pnpm --filter @mosaicstack/types build` — PASS.
- `pnpm --filter @mosaicstack/db build` — PASS.
- `pnpm --filter @mosaicstack/storage build` — PASS.
- `pnpm --filter @mosaicstack/brain build` — PASS.
- `pnpm --filter @mosaicstack/queue build` — PASS.
- `pnpm --filter @mosaicstack/config build` — PASS.
- `pnpm --filter @mosaicstack/auth build` — PASS.
- `pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS skipped when `FEDERATED_INTEGRATION` unset (5 skipped).
- `FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS (5 tests) after local `docker compose up -d postgres` + `pnpm --filter @mosaicstack/db db:push`.
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
- `pnpm format:check` — PASS.
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS; approve, no findings.
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS; risk level none, no findings.
## Push / PR
- #683 landed in `next`; branch rebased onto `origin/next` before push.
- CI is serialized; run queue guard before push.

View File

@@ -1,40 +0,0 @@
# Installer `--next` fast npm lane — 2026-06-25
## Scope
Flip `tools/install.sh --next` from source-build-first to fast npm `@next` first, with source fallback.
## Registry reality check
Gitea npm registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`
Verified before implementation:
- `@mosaicstack/mosaic@next` resolves to `0.0.49-next.1633`.
- `@mosaicstack/gateway@next` resolves to `0.0.7-next.1633`.
- `@mosaicstack/gateway` dist-tags include `latest: 0.0.6` and `next: 0.0.7-next.1633`.
- `apps/gateway/package.json` is non-private and has Gitea npm `publishConfig`.
Conclusion: the installer can fast-install both CLI and gateway npm packages for `--next`. The gateway Docker `gateway:sha-<short>` remains the deployment/harness artifact; the npm gateway package is valid for the installer global package path.
## Behavior
- `--next` with no explicit ref:
1. framework archive from `next`;
2. resolve `@mosaicstack/gateway@next` and `@mosaicstack/mosaic@next`;
3. require both resolved versions to share the same `next.<pipeline>` suffix;
4. install the exact resolved package versions;
5. set `MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1` so wizard does not overwrite the prerelease gateway;
6. if either package is missing/unreachable/mismatched/fails, fall back to existing source build at `next`.
- `--dev` remains pure source build.
- explicit `--ref` / `MOSAIC_REF` still wins over `--next` and uses the source path for that exact ref.
## Install detail
The installer writes the scoped npmrc mapping (`@mosaicstack:registry=...`) and then runs npm install without overriding npm's default registry. Passing `--registry=<gitea>` to `npm install` forces public transitive dependencies (for example `@anthropic-ai/sdk`) to resolve from Gitea and breaks the fast path; the scoped npmrc mapping is the correct split-registry behavior.
## Verification notes
- Added `tools/install-next-lane.test.sh` with a fake npm/source harness for exact-version fast install, registry failure source fallback, explicit-ref precedence, and mismatched suffix warning.
- Wired the installer harness into `pnpm test` via `pnpm run test:installer`.
- Real temp-prefix fast install succeeded with `@mosaicstack/gateway@0.0.7-next.1633` and `@mosaicstack/mosaic@0.0.49-next.1633`.

View File

@@ -1,35 +0,0 @@
# Scratchpad — installer `--next` lane
## Objective
Add a prerelease installer lane for the permanent `next` integration branch.
## Scope
- `tools/install.sh`
- README/install documentation
- Follow-up design note for future npm `@next` prerelease publishing
## Plan
1. Add `--next` and `MOSAIC_NEXT=1` as source-build shorthand for `next`.
2. Preserve explicit ref precedence: `MOSAIC_REF` and `--ref` win over `--next`.
3. Update installer source display/help text.
4. Document three lanes:
- stable npm `@latest`
- prerelease `--next`
- contributor `--dev --ref X`
5. Run shell and repo gates locally, then hold before push/PR until runner serialization greenlight.
## Verification
- `bash -n tools/install.sh` — pass.
- `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable tools/install.sh` — pass.
- `bash tools/install.sh --check --framework --next` — source display shows `ref: next, --next prerelease lane`.
- `bash tools/install.sh --check --cli --next --ref feature-x` — source display shows explicit ref wins.
- `MOSAIC_NEXT=1 MOSAIC_REF=feature-env bash tools/install.sh --check --cli` — source display shows explicit env ref wins.
- `pnpm install --frozen-lockfile --prefer-offline --store-dir /home/jarvis/.local/share/pnpm/store` — pass (local override for repo `.npmrc` CI store path).
- `pnpm typecheck` — pass (41 successful tasks).
- `pnpm lint` — pass (23 successful tasks).
- `pnpm format:check` — pass.
- `bash tools/e2e-install-test.sh` — attempted; current baseline fails during gateway health after stable registry install because Valkey is unavailable in the clean container. The `tools/install.sh --yes --no-auto-launch` stage itself completed before the downstream gateway verification failure.

View File

@@ -7,8 +7,7 @@
"dev": "turbo run dev", "dev": "turbo run dev",
"lint": "turbo run lint", "lint": "turbo run lint",
"typecheck": "turbo run typecheck", "typecheck": "turbo run typecheck",
"test": "turbo run test && pnpm run test:installer", "test": "turbo run test",
"test:installer": "bash tools/install-next-lane.test.sh",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"prepare": "husky" "prepare": "husky"

View File

@@ -43,16 +43,6 @@ The installer:
- Runs a health audit - Runs a health audit
- Detects existing installs and preserves local files (SOUL.md, USER.md, etc.) - Detects existing installs and preserves local files (SOUL.md, USER.md, etc.)
### Install lanes
| Lane | Command | Use when | Source |
| ------------------------ | ------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | You want the released framework and CLI | npm `@mosaicstack/mosaic@latest` + `main` |
| Prerelease integration | `bash tools/install.sh --next` | You want the permanent `next` integration lane | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are validating a branch before release | Build-from-source at the requested git ref |
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` wins and uses the source path.
## First Run ## First Run
After install, open a new terminal (or `source ~/.bashrc`) and run: After install, open a new terminal (or `source ~/.bashrc`) and run:
@@ -184,9 +174,7 @@ The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by
bash tools/install.sh --check # Version check only bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI) bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework) bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --next # Prerelease lane: npm @next, source fallback bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
``` ```
## Universal Skills ## Universal Skills

View File

@@ -1,222 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
FAKE_BIN="$TMP/bin"
HOME_DIR="$TMP/home"
PREFIX="$TMP/prefix"
MOSAIC_HOME="$TMP/mosaic"
STATE="$TMP/state"
LOG="$TMP/npm.log"
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
#!/usr/bin/env bash
set -euo pipefail
LOG="${MOSAIC_TEST_NPM_LOG:?}"
STATE="${MOSAIC_TEST_STATE:?}"
echo "$*" >> "$LOG"
if [[ "$1" == "view" ]]; then
case "$2 $3" in
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
"@mosaicstack/mosaic version") echo "0.0.48" ;;
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "$1" == "install" ]]; then
case "$*" in
*"@mosaicstack/mosaic@0.0.49-next.999"*)
echo "0.0.49-next.999" > "$STATE/mosaic"
;;
*"@mosaicstack/gateway@0.0.7-next.999"*)
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
echo "forced gateway install failure" >&2
exit 1
fi
echo "0.0.7-next.999" > "$STATE/gateway"
;;
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
echo "0.0.0-source" > "$STATE/mosaic"
;;
*"mosaicstack-gateway-0.0.0-source.tgz"*)
echo "0.0.0-source" > "$STATE/gateway"
;;
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "$1" == "ls" ]]; then
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
node -e '
const cli = process.argv[1];
const gateway = process.argv[2];
const dependencies = {};
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
process.stdout.write(JSON.stringify({ dependencies }));
' "$cli" "$gateway"
exit 0
fi
echo "unexpected npm command: $*" >&2
exit 1
FAKE_NPM
chmod +x "$FAKE_BIN/npm"
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
#!/usr/bin/env bash
set -euo pipefail
# The fake tar creates the source tree; curl only needs to keep the pipe alive.
exit 0
FAKE_CURL
chmod +x "$FAKE_BIN/curl"
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
#!/usr/bin/env bash
set -euo pipefail
dest=""
while [[ $# -gt 0 ]]; do
case "$1" in
-C) dest="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "$dest" ]]; then
echo "fake tar missing -C destination" >&2
exit 1
fi
mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway"
FAKE_TAR
chmod +x "$FAKE_BIN/tar"
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
#!/usr/bin/env bash
set -euo pipefail
LOG="${MOSAIC_TEST_NPM_LOG:?}"
echo "pnpm $*" >> "$LOG"
if [[ "$1" == "pack" ]]; then
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--pack-destination) out="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "$out" ]]; then
echo "fake pnpm pack missing destination" >&2
exit 1
fi
mkdir -p "$out"
case "$PWD" in
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
esac
exit 0
fi
# install/build commands are no-ops in this harness.
exit 0
FAKE_PNPM
chmod +x "$FAKE_BIN/pnpm"
reset_state() {
: > "$LOG"
rm -f "$STATE"/*
}
reset_state
echo "[test] --next fast path pins resolved package versions"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
)"
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
grep -qF 'install -g @mosaicstack/gateway@0.0.7-next.999' "$LOG"
grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG"
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
echo "expected exact-version installs, found mutable @next install" >&2
exit 1
fi
if grep -qF 'Downloading source from next' <<<"$OUTPUT"; then
echo "fast path unexpectedly fell back to source" >&2
exit 1
fi
reset_state
echo "[test] fast path failure falls back to source build"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
)"
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
grep -qF 'Downloading source from next' <<<"$OUTPUT"
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG"
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
reset_state
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
)"
grep -qF 'explicit ref wins, build-from-source' <<<"$OUTPUT"
if grep -qF '@next version' "$LOG"; then
echo "explicit ref should not query @next dist-tags" >&2
exit 1
fi
reset_state
echo "[test] --check --next warns on mismatched prerelease pipeline suffixes"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --check --cli --next
)"
grep -qF '@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source.' <<<"$OUTPUT"
echo "[test] installer next lane tests passed"

View File

@@ -16,10 +16,6 @@
# --framework Install/upgrade framework only (skip npm CLI) # --framework Install/upgrade framework only (skip npm CLI)
# --cli Install/upgrade npm CLI only (skip framework) # --cli Install/upgrade npm CLI only (skip framework)
# --ref <branch> Git ref for framework archive (default: main) # --ref <branch> Git ref for framework archive (default: main)
# --next Prerelease lane: try fast npm @next install for CLI +
# gateway from the Gitea registry, then fall back to a
# source build at next if unavailable. Explicit
# --ref/MOSAIC_REF wins and uses the source path.
# --dev Build CLI + gateway FROM SOURCE at --ref instead of the # --dev Build CLI + gateway FROM SOURCE at --ref instead of the
# registry @latest. Zero registry writes — packs local # registry @latest. Zero registry writes — packs local
# tarballs and installs them globally. Use to test a branch # tarballs and installs them globally. Use to test a branch
@@ -35,7 +31,6 @@
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global) # MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
# MOSAIC_NO_COLOR — disable colour (set to 1) # MOSAIC_NO_COLOR — disable colour (set to 1)
# MOSAIC_REF — git ref for framework (default: main) # MOSAIC_REF — git ref for framework (default: main)
# MOSAIC_NEXT — equivalent to --next (set to 1)
# MOSAIC_DEV — equivalent to --dev (set to 1) # MOSAIC_DEV — equivalent to --dev (set to 1)
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1) # MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
@@ -54,12 +49,7 @@ FLAG_NO_AUTO_LAUNCH=false
FLAG_YES=false FLAG_YES=false
FLAG_UNINSTALL=false FLAG_UNINSTALL=false
FLAG_DEV=false FLAG_DEV=false
FLAG_NEXT=false
GIT_REF="${MOSAIC_REF:-main}" GIT_REF="${MOSAIC_REF:-main}"
GIT_REF_EXPLICIT=false
if [[ -n "${MOSAIC_REF:-}" ]]; then
GIT_REF_EXPLICIT=true
fi
# MOSAIC_ASSUME_YES env var acts the same as --yes # MOSAIC_ASSUME_YES env var acts the same as --yes
if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
@@ -71,24 +61,13 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
FLAG_DEV=true FLAG_DEV=true
fi fi
# MOSAIC_NEXT env var acts the same as --next: fast npm @next install with
# source fallback from the permanent next integration branch unless
# MOSAIC_REF/--ref explicitly wins.
if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
FLAG_NEXT=true
if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then
GIT_REF="next"
fi
fi
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--check) FLAG_CHECK=true; shift ;; --check) FLAG_CHECK=true; shift ;;
--framework) FLAG_CLI=false; shift ;; --framework) FLAG_CLI=false; shift ;;
--cli) FLAG_FRAMEWORK=false; shift ;; --cli) FLAG_FRAMEWORK=false; shift ;;
--ref) GIT_REF="${2:-main}"; GIT_REF_EXPLICIT=true; shift 2 ;; --ref) GIT_REF="${2:-main}"; shift 2 ;;
--dev) FLAG_DEV=true; shift ;; --dev) FLAG_DEV=true; shift ;;
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
--yes|-y) FLAG_YES=true; shift ;; --yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;; --no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;; --uninstall) FLAG_UNINSTALL=true; shift ;;
@@ -96,24 +75,12 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
# Explicit refs represent a request for that exact source tree. Keep --next as
# a lane selector, but do not install the registry @next package for a different
# ref than the permanent next branch.
if [[ "$FLAG_NEXT" == "true" && "$GIT_REF_EXPLICIT" == "true" ]]; then
FLAG_DEV=true
fi
if [[ "$FLAG_YES" == "true" ]]; then
export MOSAIC_ASSUME_YES=1
fi
# ─── constants ──────────────────────────────────────────────────────────────── # ─── constants ────────────────────────────────────────────────────────────────
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}" REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}"
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}" SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}" PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
CLI_PKG="${SCOPE}/mosaic" CLI_PKG="${SCOPE}/mosaic"
GATEWAY_PKG="${SCOPE}/gateway"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack" REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz" ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
@@ -128,20 +95,6 @@ fi
WORK_DIR="" WORK_DIR=""
EXTRACTED_DIR="" EXTRACTED_DIR=""
newest_matching_file() {
local dir="$1"
local pattern="$2"
local matches=()
[[ -d "$dir" ]] || return 0
shopt -s nullglob
# shellcheck disable=SC2206 # Intentional glob expansion for caller-provided file pattern.
matches=("$dir"/$pattern)
shopt -u nullglob
[[ "${#matches[@]}" -gt 0 ]] || return 0
# shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS.
ls -1t "${matches[@]}" 2>/dev/null | head -1
}
# ─── uninstall path ─────────────────────────────────────────────────────────── # ─── uninstall path ───────────────────────────────────────────────────────────
# Shell-level uninstall for when the CLI is broken or not available. # Shell-level uninstall for when the CLI is broken or not available.
# Handles: framework directory, npm CLI package, npmrc scope line. # Handles: framework directory, npm CLI package, npmrc scope line.
@@ -205,7 +158,7 @@ if [[ "$FLAG_UNINSTALL" == "true" ]]; then
# Find most recent backup # Find most recent backup
backup="" backup=""
if [[ -d "$dir" ]]; then if [[ -d "$dir" ]]; then
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" backup="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
fi fi
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
cp "$backup" "$dest" cp "$backup" "$dest"
@@ -261,22 +214,6 @@ fail() { echo "${R}✖${RESET} $*" >&2; }
dim() { echo "${DIM}$*${RESET}"; } dim() { echo "${DIM}$*${RESET}"; }
step() { echo ""; echo "${BOLD}$*${RESET}"; } step() { echo ""; echo "${BOLD}$*${RESET}"; }
is_next_registry_lane() {
[[ "$FLAG_NEXT" == "true" && "$FLAG_DEV" == "false" && "$GIT_REF" == "next" && "$GIT_REF_EXPLICIT" == "false" ]]
}
source_ref_details() {
if is_next_registry_lane; then
echo "ref: next, --next prerelease lane"
elif [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then
echo "ref: next, --next prerelease lane (build-from-source)"
elif [[ "$FLAG_NEXT" == "true" ]]; then
echo "ref: ${GIT_REF}, --next requested, explicit ref wins"
else
echo "ref: ${GIT_REF}"
fi
}
# ─── helpers ────────────────────────────────────────────────────────────────── # ─── helpers ──────────────────────────────────────────────────────────────────
require_cmd() { require_cmd() {
@@ -299,43 +236,10 @@ installed_cli_version() {
fi fi
} }
installed_gateway_version() {
local json
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
if [[ -n "$json" ]]; then
node -e "
const d = JSON.parse(process.argv[1]);
const v = d?.dependencies?.['${GATEWAY_PKG}']?.version ?? '';
process.stdout.write(v);
" "$json" 2>/dev/null || true
fi
}
latest_cli_version() { latest_cli_version() {
npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true
} }
next_cli_version() {
npm view "${CLI_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}
next_gateway_version() {
npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}
next_pipeline_suffix() {
printf '%s' "$1" | sed -n 's/.*-next\.\([0-9][0-9]*\)$/\1/p'
}
next_versions_share_pipeline() {
local cli_next="$1"
local gateway_next="$2"
local cli_pipeline gateway_pipeline
cli_pipeline="$(next_pipeline_suffix "$cli_next")"
gateway_pipeline="$(next_pipeline_suffix "$gateway_next")"
[[ -n "$cli_pipeline" && -n "$gateway_pipeline" && "$cli_pipeline" == "$gateway_pipeline" ]]
}
version_lt() { version_lt() {
node -e " node -e "
const a=process.argv[1], b=process.argv[2]; const a=process.argv[1], b=process.argv[2];
@@ -428,8 +332,8 @@ install_cli_from_source() {
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' ( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
local cli_tgz gw_tgz local cli_tgz gw_tgz
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')" cli_tgz="$(ls -1t "$out_dir"/mosaicstack-mosaic-*.tgz 2>/dev/null | head -1)"
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')" gw_tgz="$(ls -1t "$out_dir"/mosaicstack-gateway-*.tgz 2>/dev/null | head -1)"
if [[ ! -f "$cli_tgz" ]]; then if [[ ! -f "$cli_tgz" ]]; then
fail "CLI tarball was not produced by pnpm pack." fail "CLI tarball was not produced by pnpm pack."
@@ -451,49 +355,6 @@ install_cli_from_source() {
ok "Installed from source: CLI $(installed_cli_version)" ok "Installed from source: CLI $(installed_cli_version)"
} }
install_next_cli_from_registry() {
local cli_next gateway_next
cli_next="$(next_cli_version)"
gateway_next="$(next_gateway_version)"
if [[ -z "$cli_next" ]]; then
warn "${CLI_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if [[ -z "$gateway_next" ]]; then
warn "${GATEWAY_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if ! next_versions_share_pipeline "$cli_next" "$gateway_next"; then
warn "@next CLI/gateway versions do not share a pipeline suffix (${cli_next}, ${gateway_next})."
return 1
fi
info "Installing ${CLI_PKG}@${cli_next} from registry…"
if ! npm install -g "${CLI_PKG}@${cli_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast CLI @next install failed."
return 1
fi
info "Installing ${GATEWAY_PKG}@${gateway_next} from registry…"
if ! npm install -g "${GATEWAY_PKG}@${gateway_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast gateway @next install failed."
return 1
fi
local installed_cli installed_gateway
installed_cli="$(installed_cli_version)"
installed_gateway="$(installed_gateway_version)"
if [[ "$installed_cli" != "$cli_next" || "$installed_gateway" != "$gateway_next" ]]; then
warn "Installed @next versions did not match resolved versions (CLI: ${installed_cli:-missing}, gateway: ${installed_gateway:-missing})."
return 1
fi
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
}
# ─── preflight ──────────────────────────────────────────────────────────────── # ─── preflight ────────────────────────────────────────────────────────────────
require_cmd node require_cmd node
@@ -527,7 +388,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
else else
dim " Installed: (none)" dim " Installed: (none)"
fi fi
dim " Source: ${REPO_BASE} ($(source_ref_details))" dim " Source: ${REPO_BASE} (ref: ${GIT_REF})"
echo "" echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then if [[ "$FLAG_CHECK" == "true" ]]; then
@@ -594,12 +455,8 @@ if [[ "$FLAG_CLI" == "true" ]]; then
fi fi
CURRENT="$(installed_cli_version)" CURRENT="$(installed_cli_version)"
NEXT_GATEWAY=""
if [[ "$FLAG_DEV" == "true" ]]; then if [[ "$FLAG_DEV" == "true" ]]; then
LATEST="" LATEST=""
elif is_next_registry_lane; then
LATEST="$(next_cli_version)"
NEXT_GATEWAY="$(next_gateway_version)"
else else
LATEST="$(latest_cli_version)" LATEST="$(latest_cli_version)"
fi fi
@@ -611,19 +468,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
fi fi
if [[ "$FLAG_DEV" == "true" ]]; then if [[ "$FLAG_DEV" == "true" ]]; then
dim " Source: ${REPO_BASE} ($(source_ref_details), build-from-source)" dim " Source: ${REPO_BASE} (ref: ${GIT_REF}, build-from-source)"
elif is_next_registry_lane; then
if [[ -n "$LATEST" ]]; then
dim " Next CLI: ${CLI_PKG}@${LATEST}"
else
dim " Next CLI: (registry @next unreachable)"
fi
if [[ -n "$NEXT_GATEWAY" ]]; then
dim " Next GW: ${GATEWAY_PKG}@${NEXT_GATEWAY}"
else
dim " Next GW: (registry @next unreachable)"
fi
dim " Fallback: ${REPO_BASE} (ref: next, build-from-source)"
elif [[ -n "$LATEST" ]]; then elif [[ -n "$LATEST" ]]; then
dim " Latest: ${CLI_PKG}@${LATEST}" dim " Latest: ${CLI_PKG}@${LATEST}"
else else
@@ -634,12 +479,6 @@ if [[ "$FLAG_CLI" == "true" ]]; then
if [[ "$FLAG_CHECK" == "true" ]]; then if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ "$FLAG_DEV" == "true" ]]; then if [[ "$FLAG_DEV" == "true" ]]; then
info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)." info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
elif is_next_registry_lane; then
if [[ -n "$LATEST" && -n "$NEXT_GATEWAY" ]] && next_versions_share_pipeline "$LATEST" "$NEXT_GATEWAY"; then
ok "@next registry lane available: ${CLI_PKG}@${LATEST}, ${GATEWAY_PKG}@${NEXT_GATEWAY}."
else
warn "@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source."
fi
elif [[ -z "$LATEST" ]]; then elif [[ -z "$LATEST" ]]; then
warn "Could not reach registry." warn "Could not reach registry."
elif [[ -z "$CURRENT" ]]; then elif [[ -z "$CURRENT" ]]; then
@@ -656,23 +495,6 @@ if [[ "$FLAG_CLI" == "true" ]]; then
ensure_monorepo ensure_monorepo
install_cli_from_source install_cli_from_source
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
elif is_next_registry_lane; then
info "Next mode — trying fast npm @next install from ${REGISTRY}"
if install_next_cli_from_registry; then
:
else
warn "Falling back to source build at ref ${GIT_REF}; --next will not hard-fail on registry issues."
unset MOSAIC_GATEWAY_SKIP_NPM_INSTALL
ensure_monorepo
install_cli_from_source
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi
# PATH check for npm prefix # PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH" warn "$PREFIX/bin is not on your PATH"
@@ -781,7 +603,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
local base dir backup_path backup_val local base dir backup_path backup_val
base="$(basename "$dest")" base="$(basename "$dest")"
dir="$(dirname "$dest")" dir="$(dirname "$dest")"
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" backup_path="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
if [[ -n "$backup_path" ]]; then if [[ -n "$backup_path" ]]; then
backup_val="\"$backup_path\"" backup_val="\"$backup_path\""
else else
@@ -806,7 +628,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]" NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
fi fi
if node -e " node -e "
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const p = process.argv[1]; const p = process.argv[1];
@@ -831,11 +653,9 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
"$MANIFEST_CLI_VERSION" \ "$MANIFEST_CLI_VERSION" \
"$MANIFEST_FW_VERSION" \ "$MANIFEST_FW_VERSION" \
"$NPMRC_LINES_JSON" \ "$NPMRC_LINES_JSON" \
"$RUNTIME_COPIES" 2>/dev/null; then "$RUNTIME_COPIES" 2>/dev/null \
ok "Install manifest written: $MANIFEST_PATH" && ok "Install manifest written: $MANIFEST_PATH" \
else || warn "Could not write install manifest (non-fatal)"
warn "Could not write install manifest (non-fatal)"
fi
echo "" echo ""
ok "Done." ok "Done."