Compare commits
8 Commits
6e37ab00b9
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| 940ae3cc41 | |||
| c25a551c28 | |||
| 94d6538061 | |||
| a3c1ab923c | |||
| 838701bde2 | |||
| 86e106fcc9 | |||
| 67135d3822 | |||
| adb153428b |
@@ -1,5 +1,5 @@
|
|||||||
# Build, publish npm packages, and push Docker images
|
# Build, publish npm packages, and push Docker images
|
||||||
# Runs only on main branch push/tag
|
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
||||||
|
|
||||||
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,9 +23,21 @@ 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]
|
- branch: [main, next]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -103,6 +115,84 @@ 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
|
||||||
@@ -134,8 +224,17 @@ 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" = "main" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "next" ]; 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"
|
||||||
@@ -146,7 +245,7 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -172,7 +271,7 @@ steps:
|
|||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -30,6 +30,16 @@ 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
|
||||||
@@ -336,7 +346,9 @@ 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 --ref v1.0 # Install from a specific git ref
|
bash tools/install.sh --next # Prerelease lane: source build from next
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@ 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';
|
||||||
@@ -12,7 +14,13 @@ 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: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
controllers: [
|
||||||
|
EnrollmentController,
|
||||||
|
FederationController,
|
||||||
|
CapabilitiesController,
|
||||||
|
ListController,
|
||||||
|
GetController,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminGuard,
|
AdminGuard,
|
||||||
CaService,
|
CaService,
|
||||||
@@ -23,6 +31,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
|
FederationGetQueryService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CaService,
|
CaService,
|
||||||
@@ -33,6 +42,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
|
FederationGetQueryService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
import type { Db } from '@mosaicstack/db';
|
import {
|
||||||
|
createPgliteDb,
|
||||||
|
insights,
|
||||||
|
missionTasks,
|
||||||
|
missions,
|
||||||
|
preferences,
|
||||||
|
projects,
|
||||||
|
runPgliteMigrations,
|
||||||
|
teams,
|
||||||
|
users,
|
||||||
|
type Db,
|
||||||
|
type DbHandle,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
||||||
import { FederationListQueryService } from '../list-query.service.js';
|
import { FederationListQueryService } from '../list-query.service.js';
|
||||||
|
|
||||||
@@ -12,10 +24,192 @@ const TASK_FILTER: FederationScopeQueryFilter = {
|
|||||||
maxRowsPerQuery: 2,
|
maxRowsPerQuery: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SUBJECT_USER_ID = 'fed-m3-05-subject';
|
||||||
|
const OTHER_USER_ID = 'fed-m3-05-other';
|
||||||
|
const TEAM_ID = '05000000-0000-4000-8000-000000000001';
|
||||||
|
const UNAUTHORIZED_TEAM_ID = '05000000-0000-4000-8000-000000000002';
|
||||||
|
const PERSONAL_PROJECT_ID = '05000000-0000-4000-8000-000000000101';
|
||||||
|
const TEAM_PROJECT_ID = '05000000-0000-4000-8000-000000000102';
|
||||||
|
const UNAUTHORIZED_PROJECT_ID = '05000000-0000-4000-8000-000000000103';
|
||||||
|
const PERSONAL_MISSION_ID = '05000000-0000-4000-8000-000000000201';
|
||||||
|
const TEAM_MISSION_ID = '05000000-0000-4000-8000-000000000202';
|
||||||
|
const UNAUTHORIZED_MISSION_ID = '05000000-0000-4000-8000-000000000203';
|
||||||
|
const SUBJECT_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000301';
|
||||||
|
const OTHER_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000302';
|
||||||
|
const SUBJECT_PERSONAL_NOTE_ID = '05000000-0000-4000-8000-000000000303';
|
||||||
|
const SUBJECT_UNAUTHORIZED_NOTE_ID = '05000000-0000-4000-8000-000000000304';
|
||||||
|
const INSIGHT_ONE_ID = '05000000-0000-4000-8000-000000000401';
|
||||||
|
const INSIGHT_TWO_ID = '05000000-0000-4000-8000-000000000402';
|
||||||
|
const PREFERENCE_ONE_ID = '05000000-0000-4000-8000-000000000501';
|
||||||
|
const PREFERENCE_TWO_ID = '05000000-0000-4000-8000-000000000502';
|
||||||
|
|
||||||
|
let dbHandle: DbHandle | undefined;
|
||||||
|
|
||||||
function makeService() {
|
function makeService() {
|
||||||
return new FederationListQueryService({} as Db);
|
return new FederationListQueryService({} as Db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeDbService() {
|
||||||
|
if (!dbHandle) {
|
||||||
|
throw new Error('test DB not initialized');
|
||||||
|
}
|
||||||
|
return new FederationListQueryService(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-05 Team',
|
||||||
|
slug: 'fed-m3-05-team',
|
||||||
|
ownerId: SUBJECT_USER_ID,
|
||||||
|
managerId: SUBJECT_USER_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: UNAUTHORIZED_TEAM_ID,
|
||||||
|
name: 'FED-M3-05 Unauthorized Team',
|
||||||
|
slug: 'fed-m3-05-unauthorized-team',
|
||||||
|
ownerId: OTHER_USER_ID,
|
||||||
|
managerId: OTHER_USER_ID,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dbHandle.db.insert(projects).values([
|
||||||
|
{
|
||||||
|
id: PERSONAL_PROJECT_ID,
|
||||||
|
name: 'FED-M3-05 Personal Project',
|
||||||
|
ownerId: SUBJECT_USER_ID,
|
||||||
|
ownerType: 'user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TEAM_PROJECT_ID,
|
||||||
|
name: 'FED-M3-05 Team Project',
|
||||||
|
teamId: TEAM_ID,
|
||||||
|
ownerType: 'team',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: UNAUTHORIZED_PROJECT_ID,
|
||||||
|
name: 'FED-M3-05 Unauthorized Project',
|
||||||
|
teamId: UNAUTHORIZED_TEAM_ID,
|
||||||
|
ownerType: 'team',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dbHandle.db.insert(missions).values([
|
||||||
|
{
|
||||||
|
id: PERSONAL_MISSION_ID,
|
||||||
|
name: 'FED-M3-05 Personal Mission',
|
||||||
|
projectId: PERSONAL_PROJECT_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TEAM_MISSION_ID,
|
||||||
|
name: 'FED-M3-05 Team Mission',
|
||||||
|
projectId: TEAM_PROJECT_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: UNAUTHORIZED_MISSION_ID,
|
||||||
|
name: 'FED-M3-05 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'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const memoryCreatedAt = new Date('2026-06-24T05:00:00.000Z');
|
||||||
|
await dbHandle.db.insert(insights).values([
|
||||||
|
{
|
||||||
|
id: INSIGHT_ONE_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
content: 'first insight',
|
||||||
|
source: 'agent',
|
||||||
|
createdAt: memoryCreatedAt,
|
||||||
|
updatedAt: memoryCreatedAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: INSIGHT_TWO_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
content: 'second insight',
|
||||||
|
source: 'agent',
|
||||||
|
createdAt: memoryCreatedAt,
|
||||||
|
updatedAt: memoryCreatedAt,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dbHandle.db.insert(preferences).values([
|
||||||
|
{
|
||||||
|
id: PREFERENCE_ONE_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
key: 'fed-m3-05-pref-1',
|
||||||
|
value: { enabled: true },
|
||||||
|
createdAt: memoryCreatedAt,
|
||||||
|
updatedAt: memoryCreatedAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: PREFERENCE_TWO_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
key: 'fed-m3-05-pref-2',
|
||||||
|
value: { enabled: false },
|
||||||
|
createdAt: memoryCreatedAt,
|
||||||
|
updatedAt: memoryCreatedAt,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function stubRows(
|
function stubRows(
|
||||||
service: FederationListQueryService,
|
service: FederationListQueryService,
|
||||||
...pages: Array<Array<Record<string, unknown>>>
|
...pages: Array<Array<Record<string, unknown>>>
|
||||||
@@ -37,6 +231,17 @@ function stubRows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('FederationListQueryService', () => {
|
describe('FederationListQueryService', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
dbHandle = createPgliteDb(`memory://fed-m3-05-list-${Date.now()}`);
|
||||||
|
await runPgliteMigrations(dbHandle);
|
||||||
|
await seedNotesFixture();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await dbHandle?.close();
|
||||||
|
dbHandle = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
it('denies sensitive resources in native RBAC for M3 list reads', async () => {
|
it('denies sensitive resources in native RBAC for M3 list reads', async () => {
|
||||||
const service = makeService();
|
const service = makeService();
|
||||||
|
|
||||||
@@ -115,4 +320,109 @@ describe('FederationListQueryService', () => {
|
|||||||
'Invalid federation list cursor',
|
'Invalid federation list cursor',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws when a truncated page cannot encode a resumable cursor', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
stubRows(service, [
|
||||||
|
{ id: '2', createdAt: 'not-a-date' },
|
||||||
|
{ id: '1', createdAt: 'not-a-date' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(service.list({ filter: { ...TASK_FILTER, limit: 1 } })).rejects.toThrow(
|
||||||
|
'Federation list cursor cannot be encoded',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on unsupported resources instead of crashing pagination', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.list({
|
||||||
|
filter: {
|
||||||
|
...TASK_FILTER,
|
||||||
|
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Unsupported federation list resource');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak another user mission task notes through team-scoped note reads', async () => {
|
||||||
|
const service = makeDbService();
|
||||||
|
|
||||||
|
const result = await service.list({
|
||||||
|
filter: {
|
||||||
|
resource: 'notes',
|
||||||
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
|
includePersonal: false,
|
||||||
|
teamIds: [TEAM_ID],
|
||||||
|
limit: 10,
|
||||||
|
maxRowsPerQuery: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = result.items.map((item) => item['id']);
|
||||||
|
expect(ids).toEqual([SUBJECT_TEAM_NOTE_ID]);
|
||||||
|
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return subject personal mission task notes when includePersonal is false', async () => {
|
||||||
|
const service = makeDbService();
|
||||||
|
|
||||||
|
const result = await service.list({
|
||||||
|
filter: {
|
||||||
|
resource: 'notes',
|
||||||
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
|
includePersonal: false,
|
||||||
|
teamIds: [TEAM_ID],
|
||||||
|
limit: 10,
|
||||||
|
maxRowsPerQuery: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items.map((item) => item['id'])).not.toContain(SUBJECT_PERSONAL_NOTE_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
||||||
|
const service = makeDbService();
|
||||||
|
|
||||||
|
const result = await service.list({
|
||||||
|
filter: {
|
||||||
|
resource: 'notes',
|
||||||
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
|
includePersonal: true,
|
||||||
|
teamIds: [TEAM_ID],
|
||||||
|
limit: 10,
|
||||||
|
maxRowsPerQuery: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = result.items.map((item) => item['id']);
|
||||||
|
expect(ids).toContain(SUBJECT_PERSONAL_NOTE_ID);
|
||||||
|
expect(ids).toContain(SUBJECT_TEAM_NOTE_ID);
|
||||||
|
expect(ids).not.toContain(SUBJECT_UNAUTHORIZED_NOTE_ID);
|
||||||
|
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates memory deterministically across insights and preferences', async () => {
|
||||||
|
const service = makeDbService();
|
||||||
|
const filter: FederationScopeQueryFilter = {
|
||||||
|
resource: 'memory',
|
||||||
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
|
includePersonal: true,
|
||||||
|
teamIds: [],
|
||||||
|
limit: 2,
|
||||||
|
maxRowsPerQuery: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstPage = await service.list({ filter });
|
||||||
|
const secondPage = await service.list({ filter, cursor: firstPage.nextCursor });
|
||||||
|
const firstPageIds = firstPage.items.map((item) => item['id']);
|
||||||
|
const secondPageIds = secondPage.items.map((item) => item['id']);
|
||||||
|
const allIds = [...firstPageIds, ...secondPageIds];
|
||||||
|
|
||||||
|
expect(firstPage).toMatchObject({ truncated: true, nextCursor: expect.any(String) });
|
||||||
|
expect(firstPageIds).toEqual([INSIGHT_TWO_ID, INSIGHT_ONE_ID]);
|
||||||
|
expect(secondPageIds).toEqual([PREFERENCE_TWO_ID, PREFERENCE_ONE_ID]);
|
||||||
|
expect(new Set(allIds).size).toBe(allIds.length);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -120,6 +120,24 @@ describe('ListController', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns a federation error envelope when auth guard context is missing', async () => {
|
||||||
|
const { controller, scope, query } = makeController();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.list('tasks', {} as unknown as FastifyRequest, {}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
error: {
|
||||||
|
code: 'unauthorized',
|
||||||
|
message: 'Federation context missing',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||||
|
expect(query.list).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
||||||
const { controller, query } = makeController({
|
const { controller, query } = makeController({
|
||||||
scopeResult: {
|
scopeResult: {
|
||||||
|
|||||||
311
apps/gateway/src/federation/server/verbs/get-query.service.ts
Normal file
311
apps/gateway/src/federation/server/verbs/get-query.service.ts
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
100
apps/gateway/src/federation/server/verbs/get.controller.ts
Normal file
100
apps/gateway/src/federation/server/verbs/get.controller.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 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 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,23 +44,29 @@ export interface FederationListQueryResult<T extends object = Record<string, unk
|
|||||||
readonly truncated: boolean;
|
readonly truncated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RowObject = Record<string, unknown>;
|
type CursorSource = 'insights' | 'preferences';
|
||||||
|
const CURSOR_SOURCE = Symbol('federationCursorSource');
|
||||||
|
|
||||||
|
type RowObject = Record<string, unknown> & { readonly [CURSOR_SOURCE]?: CursorSource };
|
||||||
|
|
||||||
interface KeysetCursor {
|
interface KeysetCursor {
|
||||||
readonly createdAt: Date;
|
readonly createdAt: Date;
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
|
readonly source?: CursorSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeCursor(row: RowObject): string | undefined {
|
function encodeCursor(row: RowObject): string {
|
||||||
const createdAt = row['createdAt'];
|
const createdAt = row['createdAt'];
|
||||||
const id = row['id'];
|
const id = row['id'];
|
||||||
if (!(createdAt instanceof Date) || typeof id !== 'string') {
|
if (!(createdAt instanceof Date) || typeof id !== 'string') {
|
||||||
return undefined;
|
throw new Error('Federation list cursor cannot be encoded');
|
||||||
}
|
}
|
||||||
|
|
||||||
return Buffer.from(JSON.stringify({ createdAt: createdAt.toISOString(), id }), 'utf8').toString(
|
const source = row[CURSOR_SOURCE];
|
||||||
'base64url',
|
return Buffer.from(
|
||||||
);
|
JSON.stringify({ createdAt: createdAt.toISOString(), id, ...(source ? { source } : {}) }),
|
||||||
|
'utf8',
|
||||||
|
).toString('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeCursor(cursor: string | undefined): KeysetCursor | undefined {
|
function decodeCursor(cursor: string | undefined): KeysetCursor | undefined {
|
||||||
@@ -74,17 +80,24 @@ function decodeCursor(cursor: string | undefined): KeysetCursor | undefined {
|
|||||||
throw new Error('cursor must be an object');
|
throw new Error('cursor must be an object');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { createdAt, id } = parsed as { createdAt?: unknown; id?: unknown };
|
const { createdAt, id, source } = parsed as {
|
||||||
|
createdAt?: unknown;
|
||||||
|
id?: unknown;
|
||||||
|
source?: unknown;
|
||||||
|
};
|
||||||
if (typeof createdAt !== 'string' || typeof id !== 'string' || id.length === 0) {
|
if (typeof createdAt !== 'string' || typeof id !== 'string' || id.length === 0) {
|
||||||
throw new Error('cursor is missing createdAt or id');
|
throw new Error('cursor is missing createdAt or id');
|
||||||
}
|
}
|
||||||
|
if (source !== undefined && source !== 'insights' && source !== 'preferences') {
|
||||||
|
throw new Error('cursor source is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
const date = new Date(createdAt);
|
const date = new Date(createdAt);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
throw new Error('cursor createdAt is invalid');
|
throw new Error('cursor createdAt is invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { createdAt: date, id };
|
return { createdAt: date, id, ...(source ? { source } : {}) };
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Invalid federation list cursor');
|
throw new Error('Invalid federation list cursor');
|
||||||
}
|
}
|
||||||
@@ -102,6 +115,15 @@ function paginate<T extends RowObject>(rows: T[], limit: number): FederationList
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markCursorSource<T extends RowObject>(row: T, source: CursorSource): T {
|
||||||
|
Object.defineProperty(row, CURSOR_SOURCE, {
|
||||||
|
value: source,
|
||||||
|
enumerable: false,
|
||||||
|
configurable: false,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
function sortRows(rows: RowObject[]): RowObject[] {
|
function sortRows(rows: RowObject[]): RowObject[] {
|
||||||
return [...rows].sort((a, b) => {
|
return [...rows].sort((a, b) => {
|
||||||
const aTime = a['createdAt'] instanceof Date ? a['createdAt'].getTime() : 0;
|
const aTime = a['createdAt'] instanceof Date ? a['createdAt'].getTime() : 0;
|
||||||
@@ -159,6 +181,8 @@ export class FederationListQueryService implements FederationNativeRbacEvaluator
|
|||||||
case 'credentials':
|
case 'credentials':
|
||||||
case 'api_keys':
|
case 'api_keys':
|
||||||
return [];
|
return [];
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported federation list resource: ${String(filter.resource)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,20 +290,18 @@ export class FederationListQueryService implements FederationNativeRbacEvaluator
|
|||||||
): Promise<RowObject[]> {
|
): Promise<RowObject[]> {
|
||||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||||
const missionIds = await this.listMissionIds(projectIds);
|
const missionIds = await this.listMissionIds(projectIds);
|
||||||
const clauses = [];
|
|
||||||
|
|
||||||
if (filter.includePersonal) {
|
if (missionIds.length === 0) {
|
||||||
clauses.push(eq(missionTasks.userId, filter.subjectUserId));
|
|
||||||
}
|
|
||||||
if (missionIds.length > 0) {
|
|
||||||
clauses.push(inArray(missionTasks.missionId, missionIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clauses.length === 0) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const scopeClause = clauses.length === 1 ? clauses[0] : or(...clauses);
|
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
||||||
|
// Team visibility can narrow the mission set, but it must never widen the
|
||||||
|
// query to other users' mission task notes.
|
||||||
|
const scopeClause = and(
|
||||||
|
eq(missionTasks.userId, filter.subjectUserId),
|
||||||
|
inArray(missionTasks.missionId, missionIds),
|
||||||
|
);
|
||||||
const cursorClause = cursor
|
const cursorClause = cursor
|
||||||
? or(
|
? or(
|
||||||
lt(missionTasks.createdAt, cursor.createdAt),
|
lt(missionTasks.createdAt, cursor.createdAt),
|
||||||
@@ -313,22 +335,25 @@ export class FederationListQueryService implements FederationNativeRbacEvaluator
|
|||||||
if (!filter.includePersonal) {
|
if (!filter.includePersonal) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
if (cursor && cursor.source === undefined) {
|
||||||
|
throw new Error('Invalid federation list cursor');
|
||||||
|
}
|
||||||
|
|
||||||
const insightCursorClause = cursor
|
const rows: RowObject[] = [];
|
||||||
? or(
|
|
||||||
lt(insights.createdAt, cursor.createdAt),
|
|
||||||
and(eq(insights.createdAt, cursor.createdAt), lt(insights.id, cursor.id)),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const preferenceCursorClause = cursor
|
|
||||||
? or(
|
|
||||||
lt(preferences.createdAt, cursor.createdAt),
|
|
||||||
and(eq(preferences.createdAt, cursor.createdAt), lt(preferences.id, cursor.id)),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const [insightRows, preferenceRows] = await Promise.all([
|
// Memory spans two physical tables. To keep pagination deterministic and
|
||||||
this.db
|
// resumable without a SQL UNION, M3 emits a fixed block order: all insights
|
||||||
|
// first, then preferences. The opaque cursor records which table produced
|
||||||
|
// the boundary row, so the next page never re-applies one table's keyset to
|
||||||
|
// the other table (which could duplicate/skip rows at equal timestamps).
|
||||||
|
if (cursor?.source !== 'preferences') {
|
||||||
|
const insightCursorClause = cursor
|
||||||
|
? or(
|
||||||
|
lt(insights.createdAt, cursor.createdAt),
|
||||||
|
and(eq(insights.createdAt, cursor.createdAt), lt(insights.id, cursor.id)),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const insightRows = await this.db
|
||||||
.select({
|
.select({
|
||||||
id: insights.id,
|
id: insights.id,
|
||||||
kind: insights.source,
|
kind: insights.source,
|
||||||
@@ -342,24 +367,42 @@ export class FederationListQueryService implements FederationNativeRbacEvaluator
|
|||||||
.from(insights)
|
.from(insights)
|
||||||
.where(and(eq(insights.userId, filter.subjectUserId), insightCursorClause))
|
.where(and(eq(insights.userId, filter.subjectUserId), insightCursorClause))
|
||||||
.orderBy(desc(insights.createdAt), desc(insights.id))
|
.orderBy(desc(insights.createdAt), desc(insights.id))
|
||||||
.limit(rowLimit),
|
.limit(rowLimit);
|
||||||
this.db
|
|
||||||
.select({
|
|
||||||
id: preferences.id,
|
|
||||||
kind: preferences.category,
|
|
||||||
key: preferences.key,
|
|
||||||
value: preferences.value,
|
|
||||||
source: preferences.source,
|
|
||||||
mutable: preferences.mutable,
|
|
||||||
createdAt: preferences.createdAt,
|
|
||||||
updatedAt: preferences.updatedAt,
|
|
||||||
})
|
|
||||||
.from(preferences)
|
|
||||||
.where(and(eq(preferences.userId, filter.subjectUserId), preferenceCursorClause))
|
|
||||||
.orderBy(desc(preferences.createdAt), desc(preferences.id))
|
|
||||||
.limit(rowLimit),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return sortRows([...(insightRows as RowObject[]), ...(preferenceRows as RowObject[])]);
|
rows.push(...(insightRows as RowObject[]).map((row) => markCursorSource(row, 'insights')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = rowLimit - rows.length;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferenceCursorClause =
|
||||||
|
cursor?.source === 'preferences'
|
||||||
|
? or(
|
||||||
|
lt(preferences.createdAt, cursor.createdAt),
|
||||||
|
and(eq(preferences.createdAt, cursor.createdAt), lt(preferences.id, cursor.id)),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const preferenceRows = await this.db
|
||||||
|
.select({
|
||||||
|
id: preferences.id,
|
||||||
|
kind: preferences.category,
|
||||||
|
key: preferences.key,
|
||||||
|
value: preferences.value,
|
||||||
|
source: preferences.source,
|
||||||
|
mutable: preferences.mutable,
|
||||||
|
createdAt: preferences.createdAt,
|
||||||
|
updatedAt: preferences.updatedAt,
|
||||||
|
})
|
||||||
|
.from(preferences)
|
||||||
|
.where(and(eq(preferences.userId, filter.subjectUserId), preferenceCursorClause))
|
||||||
|
.orderBy(desc(preferences.createdAt), desc(preferences.id))
|
||||||
|
.limit(remaining);
|
||||||
|
|
||||||
|
rows.push(
|
||||||
|
...(preferenceRows as RowObject[]).map((row) => markCursorSource(row, 'preferences')),
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type { FastifyRequest } from 'fastify';
|
|||||||
import {
|
import {
|
||||||
FederationInvalidRequestError,
|
FederationInvalidRequestError,
|
||||||
FederationScopeViolationError,
|
FederationScopeViolationError,
|
||||||
|
FederationUnauthorizedError,
|
||||||
SOURCE_LOCAL,
|
SOURCE_LOCAL,
|
||||||
tagWithSource,
|
tagWithSource,
|
||||||
type FederationListResponse,
|
type FederationListResponse,
|
||||||
@@ -93,7 +94,10 @@ export class ListController {
|
|||||||
@Body() body?: FederationListRequestBody,
|
@Body() body?: FederationListRequestBody,
|
||||||
): Promise<FederationListResponse<FederatedRow>> {
|
): Promise<FederationListResponse<FederatedRow>> {
|
||||||
if (!request.federationContext) {
|
if (!request.federationContext) {
|
||||||
throw new Error('Federation context missing after auth guard');
|
throw new HttpException(
|
||||||
|
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
||||||
|
401,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedLimit = parseLimit(body);
|
const requestedLimit = parseLimit(body);
|
||||||
|
|||||||
63
docs/design/prerelease-next-dist-tag-pipeline.md
Normal file
63
docs/design/prerelease-next-dist-tag-pipeline.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# 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.
|
||||||
@@ -211,6 +211,17 @@ 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
|
||||||
|
|||||||
@@ -175,8 +175,18 @@ 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`. Flags for
|
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`.
|
||||||
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
|
||||||
|
|||||||
38
docs/scratchpads/462-fed-m3-06-get-verb.md
Normal file
38
docs/scratchpads/462-fed-m3-06-get-verb.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 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.
|
||||||
82
docs/scratchpads/B1-next-durable-publish-design.md
Normal file
82
docs/scratchpads/B1-next-durable-publish-design.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 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.
|
||||||
@@ -15,8 +15,7 @@ Implement `POST /api/federation/v1/list/:resource`.
|
|||||||
## Base / branch
|
## Base / branch
|
||||||
|
|
||||||
- Branch: `feat/federation-m3-verb-list`
|
- Branch: `feat/federation-m3-verb-list`
|
||||||
- Base: `feat/federation-m3-scope-service` (PR #672), per orchestrator, because M3-04 is not merged yet.
|
- Base: `main` after M3-04 scope service merged via PR #672 (`c739256a`).
|
||||||
- Rebase target after #672 merges: `main`.
|
|
||||||
|
|
||||||
## Implementation notes
|
## Implementation notes
|
||||||
|
|
||||||
@@ -28,10 +27,13 @@ Implement `POST /api/federation/v1/list/:resource`.
|
|||||||
- `memory`: user-owned `insights` and `preferences` rows.
|
- `memory`: user-owned `insights` and `preferences` rows.
|
||||||
- `credentials` / `api_keys`: denied by native RBAC in M3 even if present in scope; sensitive-resource implementation is not part of FED-M3-05.
|
- `credentials` / `api_keys`: denied by native RBAC in M3 even if present in scope; sensitive-resource implementation is not part of FED-M3-05.
|
||||||
- Cursor pagination uses an opaque base64url keyset cursor over `(createdAt, id)`; DB reads fetch at most `limit + 1` rows per resource query.
|
- Cursor pagination uses an opaque base64url keyset cursor over `(createdAt, id)`; DB reads fetch at most `limit + 1` rows per resource query.
|
||||||
|
- Reviewer isolation fix: `mission_tasks.notes` rows are always constrained by `missionTasks.userId = subjectUserId` and accessible mission IDs; team scope narrows missions but never widens to other users' mission task notes.
|
||||||
|
- Follow-up review fix: memory listing now uses deterministic table-block pagination (`insights` first, then `preferences`) with cursor source metadata, so one table's cursor is never applied to the other.
|
||||||
|
- Follow-up hardening: missing auth-guard context returns a structured federation `unauthorized` envelope; unsupported resources and non-encodable truncated cursors throw instead of silently crashing/truncating.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- list.controller.spec.ts list-query.service.spec.ts` — PASS (9 tests).
|
- `pnpm --filter @mosaicstack/gateway test -- list.controller.spec.ts list-query.service.spec.ts` — PASS (16 tests, including PGlite regression coverage for team-scoped notes isolation, unauthorized mission notes exclusion, `includePersonal: false`, deterministic memory pagination, missing context envelope, unsupported resource, and cursor encode failure).
|
||||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
||||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
||||||
- `pnpm format:check` — PASS.
|
- `pnpm format:check` — PASS.
|
||||||
@@ -41,11 +43,10 @@ Implement `POST /api/federation/v1/list/:resource`.
|
|||||||
|
|
||||||
## Review evidence
|
## Review evidence
|
||||||
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS after remediation; approve, no findings.
|
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS after follow-up remediation; approve, no findings.
|
||||||
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS after cursor remediation; risk level none, no findings.
|
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS after follow-up remediation; risk level none, no findings.
|
||||||
- Security-review note: read-path audit logging remains intentionally deferred to M4 per orchestrator clarification and FED-M3-05 scope.
|
- Security-review note: read-path audit logging remains intentionally deferred to M4 per orchestrator clarification and FED-M3-05 scope.
|
||||||
|
|
||||||
## Risks / follow-up
|
## Risks / follow-up
|
||||||
|
|
||||||
- This branch intentionally includes M3-04 diff until PR #672 lands; final PR must be rebased onto main after #672 merges.
|
- Read-path audit logging remains intentionally deferred to M4.
|
||||||
- Current branch base predates the M3-07 capabilities module registration; expect a small `FederationModule` rebase conflict once #672 and #674 are both on main.
|
|
||||||
|
|||||||
60
docs/scratchpads/FED-M3-10-integration-tests.md
Normal file
60
docs/scratchpads/FED-M3-10-integration-tests.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 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.
|
||||||
40
docs/scratchpads/installer-next-fast-npm-20260625.md
Normal file
40
docs/scratchpads/installer-next-fast-npm-20260625.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 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`.
|
||||||
35
docs/scratchpads/installer-next-lane-20260624.md
Normal file
35
docs/scratchpads/installer-next-lane-20260624.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 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.
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
"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",
|
"test": "turbo run test && pnpm run test:installer",
|
||||||
|
"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"
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ 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:
|
||||||
@@ -174,7 +184,9 @@ 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 --ref v1.0 # Install from a specific git ref
|
bash tools/install.sh --next # Prerelease lane: npm @next, source fallback
|
||||||
|
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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
acquireRestartLock,
|
||||||
addAgentToRoster,
|
addAgentToRoster,
|
||||||
buildAgentSendCommand,
|
buildAgentSendCommand,
|
||||||
buildAgentWatchAttachCommand,
|
buildAgentWatchAttachCommand,
|
||||||
@@ -45,6 +46,8 @@ import {
|
|||||||
removeAgentFromRoster,
|
removeAgentFromRoster,
|
||||||
resolveFleetPaths,
|
resolveFleetPaths,
|
||||||
resolvePresetFilename,
|
resolvePresetFilename,
|
||||||
|
restartLockPath,
|
||||||
|
RESTART_LOCK_STALE_MS,
|
||||||
RUNTIME_ACCEPTABLE_COMMANDS,
|
RUNTIME_ACCEPTABLE_COMMANDS,
|
||||||
serializeRosterToYaml,
|
serializeRosterToYaml,
|
||||||
VERIFY_DEFAULT_TIMEOUT_MS,
|
VERIFY_DEFAULT_TIMEOUT_MS,
|
||||||
@@ -678,6 +681,364 @@ describe('fleet command construction', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('waits for an in-flight restart to clear before relaunching (re-entry guard)', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
rosterPath,
|
||||||
|
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||||
|
'\n',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate another `mosaic fleet restart` process mid-teardown: a fresh lock
|
||||||
|
// (recent timestamp, so it is NOT treated as stale) already held.
|
||||||
|
const lockPath = restartLockPath(home);
|
||||||
|
await mkdir(dirname(lockPath), { recursive: true });
|
||||||
|
await writeFile(lockPath, `4242\n${Date.now()}\n`);
|
||||||
|
|
||||||
|
const events: string[] = [];
|
||||||
|
const runner: CommandRunner = async (command, args) => {
|
||||||
|
events.push(`run:${args[args.length - 1]}`);
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
};
|
||||||
|
// The injected sleep stands in for time passing while we wait; the in-flight
|
||||||
|
// restart "finishes" (releases its lock) after the first poll.
|
||||||
|
let sleeps = 0;
|
||||||
|
const sleepFn: SleepFn = async () => {
|
||||||
|
sleeps += 1;
|
||||||
|
events.push(`sleep:${sleeps}`);
|
||||||
|
await rm(lockPath, { force: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
|
||||||
|
|
||||||
|
// It must have waited at least once before issuing any systemctl restart.
|
||||||
|
expect(sleeps).toBeGreaterThan(0);
|
||||||
|
const firstSleep = events.findIndex((e) => e.startsWith('sleep:'));
|
||||||
|
const firstRun = events.findIndex((e) => e.startsWith('run:'));
|
||||||
|
expect(firstSleep).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(firstRun).toBeGreaterThan(firstSleep);
|
||||||
|
|
||||||
|
// And it still performs the full restart once the lock clears.
|
||||||
|
expect(events).toContain('run:mosaic-tmux-holder.service');
|
||||||
|
expect(events).toContain('run:mosaic-agent@coder0.service');
|
||||||
|
|
||||||
|
// The lock is released after the restart completes.
|
||||||
|
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('breaks a stale restart lock and proceeds without waiting', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
rosterPath,
|
||||||
|
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||||
|
'\n',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A lock left behind by a crashed owner: timestamp older than the stale window.
|
||||||
|
const lockPath = restartLockPath(home);
|
||||||
|
await mkdir(dirname(lockPath), { recursive: true });
|
||||||
|
await writeFile(lockPath, `4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\n`);
|
||||||
|
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const runner: CommandRunner = async (command, args) => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
};
|
||||||
|
const sleepFn = vi.fn<SleepFn>(async () => {});
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
|
||||||
|
|
||||||
|
// Stale lock is broken immediately — no waiting.
|
||||||
|
expect(sleepFn).not.toHaveBeenCalled();
|
||||||
|
expect(calls).toEqual([
|
||||||
|
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
|
||||||
|
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
|
||||||
|
]);
|
||||||
|
// The stale lock is gone once the restart completes.
|
||||||
|
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the restart lock so a subsequent restart is not blocked', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
rosterPath,
|
||||||
|
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||||
|
'\n',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const runner: CommandRunner = async (command, args) => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
};
|
||||||
|
const sleepFn = vi.fn<SleepFn>(async () => {});
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
|
||||||
|
|
||||||
|
// Two sequential restarts both run fully and neither has to wait.
|
||||||
|
expect(sleepFn).not.toHaveBeenCalled();
|
||||||
|
expect(calls).toEqual([
|
||||||
|
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
|
||||||
|
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
|
||||||
|
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
|
||||||
|
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guards the single-agent restart path behind the in-flight restart lock', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
rosterPath,
|
||||||
|
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||||
|
'\n',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A full restart is mid-flight (lock held); a single-agent restart re-enters.
|
||||||
|
const lockPath = restartLockPath(home);
|
||||||
|
await mkdir(dirname(lockPath), { recursive: true });
|
||||||
|
await writeFile(lockPath, `4242\n${Date.now()}\n`);
|
||||||
|
|
||||||
|
const events: string[] = [];
|
||||||
|
const runner: CommandRunner = async (command, args) => {
|
||||||
|
events.push(`run:${args[args.length - 1]}`);
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
};
|
||||||
|
let sleeps = 0;
|
||||||
|
const sleepFn: SleepFn = async () => {
|
||||||
|
sleeps += 1;
|
||||||
|
events.push(`sleep:${sleeps}`);
|
||||||
|
await rm(lockPath, { force: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart', 'coder0']);
|
||||||
|
|
||||||
|
// The single-agent restart waits for the in-flight restart before acting.
|
||||||
|
expect(sleeps).toBeGreaterThan(0);
|
||||||
|
const firstSleep = events.findIndex((e) => e.startsWith('sleep:'));
|
||||||
|
const firstRun = events.findIndex((e) => e.startsWith('run:'));
|
||||||
|
expect(firstSleep).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(firstRun).toBeGreaterThan(firstSleep);
|
||||||
|
// Only the named agent is restarted; the holder is untouched.
|
||||||
|
expect(events).toContain('run:mosaic-agent@coder0.service');
|
||||||
|
expect(events).not.toContain('run:mosaic-tmux-holder.service');
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a timed-out owner drop a lock another restart broke and re-owned', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
const runDir = join(home, 'fleet', 'run');
|
||||||
|
await mkdir(runDir, { recursive: true });
|
||||||
|
const lockPath = restartLockPath(home);
|
||||||
|
const tokenOf = async (): Promise<string> => {
|
||||||
|
const raw = await readFile(lockPath, 'utf8');
|
||||||
|
return raw.split('\n')[2]?.trim() ?? '';
|
||||||
|
};
|
||||||
|
const sleepFn = vi.fn<SleepFn>(async () => {});
|
||||||
|
|
||||||
|
// R1 acquires the lock and begins a restart that then hangs.
|
||||||
|
const r1 = await acquireRestartLock(home, sleepFn);
|
||||||
|
const tokenR1 = await tokenOf();
|
||||||
|
expect(tokenR1).not.toBe('');
|
||||||
|
|
||||||
|
// The hung R1 leaves a stale lock: rewrite its timestamp into the past while
|
||||||
|
// preserving R1's token — exactly the on-disk state a stuck owner leaves.
|
||||||
|
await writeFile(lockPath, `4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\n${tokenR1}\n`);
|
||||||
|
|
||||||
|
// R2 re-enters, sees the stale lock, and atomically takes ownership.
|
||||||
|
const r2 = await acquireRestartLock(home, sleepFn);
|
||||||
|
const tokenR2 = await tokenOf();
|
||||||
|
expect(tokenR2).not.toBe(tokenR1);
|
||||||
|
expect(sleepFn).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// R1 finally finishes and releases. It must NOT delete R2's lock — otherwise
|
||||||
|
// a third restart (R3) could acquire and interleave with R2 still running.
|
||||||
|
await r1.release();
|
||||||
|
expect(await tokenOf()).toBe(tokenR2);
|
||||||
|
|
||||||
|
// R2 releases cleanly and the lock is gone.
|
||||||
|
await r2.release();
|
||||||
|
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
|
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets only one of several concurrent breakers proceed past a stale lock', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
const lockPath = restartLockPath(home);
|
||||||
|
await mkdir(dirname(lockPath), { recursive: true });
|
||||||
|
|
||||||
|
// A stale lock left by a crashed owner: every concurrent re-entrant restart
|
||||||
|
// will judge it stale and try to break it at the same instant. Breaking must
|
||||||
|
// NOT grant ownership — only the atomic re-create may — so exactly one
|
||||||
|
// contender can ever hold the lock at a time. (The v2 fix wrote our own token
|
||||||
|
// during the break and read it back, so two breakers each saw their own token
|
||||||
|
// and BOTH proceeded; this guards that regression.)
|
||||||
|
await writeFile(
|
||||||
|
lockPath,
|
||||||
|
`4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\nstale-owner-token\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Yielding sleep so a waiting contender lets the current owner finish and
|
||||||
|
// release before it re-contends, instead of spinning the microtask queue.
|
||||||
|
const sleepFn: SleepFn = async () => {
|
||||||
|
await new Promise((res) => setTimeout(res, 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
let active = 0;
|
||||||
|
let maxActive = 0;
|
||||||
|
const tokens: string[] = [];
|
||||||
|
const tokenOf = async (): Promise<string> => {
|
||||||
|
const raw = await readFile(lockPath, 'utf8');
|
||||||
|
return raw.split('\n')[2]?.trim() ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// One "restart" = acquire the lock, do work in the critical section, release.
|
||||||
|
const restartOnce = async (): Promise<void> => {
|
||||||
|
const guard = await acquireRestartLock(home, sleepFn);
|
||||||
|
active += 1;
|
||||||
|
maxActive = Math.max(maxActive, active);
|
||||||
|
// Record the token we own while we hold it, then yield to interleave with
|
||||||
|
// any other contender that might (wrongly) believe it owns the lock too.
|
||||||
|
tokens.push(await tokenOf());
|
||||||
|
await new Promise((res) => setTimeout(res, 0));
|
||||||
|
active -= 1;
|
||||||
|
await guard.release();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Three breakers race the single stale lock simultaneously.
|
||||||
|
await Promise.all([restartOnce(), restartOnce(), restartOnce()]);
|
||||||
|
|
||||||
|
// Mutual exclusion held: never two owners at once despite concurrent breaks.
|
||||||
|
expect(maxActive).toBe(1);
|
||||||
|
// Each acquire owned with its own distinct token — no two ever shared it.
|
||||||
|
expect(new Set(tokens).size).toBe(3);
|
||||||
|
// The lock is fully released at the end.
|
||||||
|
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets exactly one of two breakers take over a stale lock while the other waits', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
const lockPath = restartLockPath(home);
|
||||||
|
await mkdir(dirname(lockPath), { recursive: true });
|
||||||
|
|
||||||
|
// A single stale lock both contenders will judge stale at the same instant.
|
||||||
|
// Every transition runs under the registry mutex, so only one may take the
|
||||||
|
// lock over; the other must observe a now-fresh owner and WAIT/re-evaluate
|
||||||
|
// rather than also taking over. (A content-blind clobber let both believe
|
||||||
|
// they owned it — this asserts the mutex-gated CAS takeover instead.)
|
||||||
|
await writeFile(
|
||||||
|
lockPath,
|
||||||
|
`4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\nstale-owner-token\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Barrier the winner holds against until the loser has observed the lock
|
||||||
|
// fresh and waited at least once — forcing the exact interleaving where one
|
||||||
|
// proceeds while the other waits, deterministically rather than by timing.
|
||||||
|
let resolveLoserWaited: () => void = () => {};
|
||||||
|
const loserWaited = new Promise<void>((res) => {
|
||||||
|
resolveLoserWaited = res;
|
||||||
|
});
|
||||||
|
let sleeps = 0;
|
||||||
|
const sleepFn: SleepFn = async () => {
|
||||||
|
sleeps += 1;
|
||||||
|
resolveLoserWaited();
|
||||||
|
await new Promise((res) => setTimeout(res, 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
let active = 0;
|
||||||
|
let maxActive = 0;
|
||||||
|
const tokens: string[] = [];
|
||||||
|
const tokenOf = async (): Promise<string> => {
|
||||||
|
const raw = await readFile(lockPath, 'utf8');
|
||||||
|
return raw.split('\n')[2]?.trim() ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
let firstOwner = true;
|
||||||
|
const restartOnce = async (): Promise<void> => {
|
||||||
|
const guard = await acquireRestartLock(home, sleepFn);
|
||||||
|
active += 1;
|
||||||
|
maxActive = Math.max(maxActive, active);
|
||||||
|
tokens.push(await tokenOf());
|
||||||
|
if (firstOwner) {
|
||||||
|
// Winner: keep holding the lock until the loser has waited once, so the
|
||||||
|
// loser is guaranteed to see a FRESH owner (not the stale one) and back
|
||||||
|
// off — proving it could not also take over.
|
||||||
|
firstOwner = false;
|
||||||
|
await loserWaited;
|
||||||
|
} else {
|
||||||
|
await new Promise((res) => setTimeout(res, 0));
|
||||||
|
}
|
||||||
|
active -= 1;
|
||||||
|
await guard.release();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Exactly two breakers race the single stale lock.
|
||||||
|
await Promise.all([restartOnce(), restartOnce()]);
|
||||||
|
|
||||||
|
// Mutual exclusion: never two owners at once (if both took over the stale
|
||||||
|
// lock, this would be 2).
|
||||||
|
expect(maxActive).toBe(1);
|
||||||
|
// Both eventually owned, each with its own distinct token.
|
||||||
|
expect(new Set(tokens).size).toBe(2);
|
||||||
|
// The loser observed the winner's fresh lock and waited — it did NOT also
|
||||||
|
// take over the stale lock.
|
||||||
|
expect(sleeps).toBeGreaterThanOrEqual(1);
|
||||||
|
// The lock is fully released at the end.
|
||||||
|
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => {
|
it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => {
|
||||||
const home = await tempDir();
|
const home = await tempDir();
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { constants } from 'node:fs';
|
import { constants } from 'node:fs';
|
||||||
import { access, chmod, copyFile, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
import {
|
||||||
|
access,
|
||||||
|
chmod,
|
||||||
|
copyFile,
|
||||||
|
mkdir,
|
||||||
|
open,
|
||||||
|
readFile,
|
||||||
|
stat,
|
||||||
|
unlink,
|
||||||
|
writeFile,
|
||||||
|
} from 'node:fs/promises';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { homedir, hostname, userInfo } from 'node:os';
|
import { homedir, hostname, userInfo } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -533,6 +544,295 @@ export function buildFleetServiceCommand(action: FleetServiceAction, agentName?:
|
|||||||
return ['systemctl', '--user', action, service];
|
return ['systemctl', '--user', action, service];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Poll interval (ms) while waiting for an in-flight restart's lock to clear. */
|
||||||
|
export const RESTART_LOCK_POLL_INTERVAL_MS = 250;
|
||||||
|
/**
|
||||||
|
* Maximum time (ms) a re-entrant restart waits for the in-flight restart to
|
||||||
|
* finish before it breaks the lock and proceeds anyway. A bound is required so
|
||||||
|
* a crashed holder of the lock can never deadlock the fleet permanently.
|
||||||
|
*/
|
||||||
|
export const RESTART_LOCK_MAX_WAIT_MS = 30_000;
|
||||||
|
/**
|
||||||
|
* Age (ms) past which a restart lock is treated as stale (its owner died
|
||||||
|
* without releasing it) and is broken immediately rather than waited on.
|
||||||
|
*/
|
||||||
|
export const RESTART_LOCK_STALE_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the path of the cross-process restart lock for a given Mosaic home.
|
||||||
|
* Kept strictly under `<mosaicHome>/fleet/run` (not the heartbeat env override)
|
||||||
|
* so the lock is scoped to the same fleet the restart acts on.
|
||||||
|
*/
|
||||||
|
export function restartLockPath(mosaicHome: string): string {
|
||||||
|
return join(mosaicHome, 'fleet', 'run', 'restart.lock');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A held restart lock; `release()` removes the lock file iff we still own it. */
|
||||||
|
interface RestartGuard {
|
||||||
|
release(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lock-file contents: pid (informational), timestamp, and a unique owner token. */
|
||||||
|
function formatRestartLockContent(token: string): string {
|
||||||
|
return `${process.pid}\n${Date.now()}\n${token}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the owner token (line 3) from a lock file, or null if the file is
|
||||||
|
* missing/unreadable/tokenless. The token is what makes release and break
|
||||||
|
* ownership-safe: a process only ever acts on a lock whose token matches its own.
|
||||||
|
*/
|
||||||
|
async function readRestartLockToken(lockPath: string): Promise<string | null> {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await readFile(lockPath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const token = raw.split('\n')[2]?.trim();
|
||||||
|
return token ? token : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when a lock's contents are stale: older than RESTART_LOCK_STALE_MS,
|
||||||
|
* or unparseable (a corrupt or partially written lock left by a crashed owner).
|
||||||
|
*/
|
||||||
|
function isRestartLockContentStale(raw: string, now: number): boolean {
|
||||||
|
const stampLine = raw.split('\n')[1] ?? '';
|
||||||
|
const stamp = Number.parseInt(stampLine.trim(), 10);
|
||||||
|
if (!Number.isFinite(stamp)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return now - stamp >= RESTART_LOCK_STALE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path of the short-lived registry mutex that guards EVERY transition of the
|
||||||
|
* restart lock (acquire, release, takeover). Held only across a few filesystem
|
||||||
|
* ops — never across the restart itself — so contention clears in microseconds.
|
||||||
|
*/
|
||||||
|
function restartMutexPath(lockPath: string): string {
|
||||||
|
return `${lockPath}.mutex`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brief back-off between registry-mutex acquisition attempts (held microseconds). */
|
||||||
|
const RESTART_MUTEX_RETRY_MS = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staleness for the internal mutex / reclaim locks, judged by the file's mtime
|
||||||
|
* rather than its CONTENT. `open(path, 'wx')` creates the inode (with a fresh
|
||||||
|
* mtime) before any token/timestamp is written into it, so a content-based check
|
||||||
|
* would momentarily see that empty file as corrupt-and-stale and could reap a
|
||||||
|
* lock another contender is still acquiring. mtime is set atomically at creation,
|
||||||
|
* so a just-created lock always reads as live; only a lock whose holder died and
|
||||||
|
* stopped touching it ages past the threshold. These locks are never held across
|
||||||
|
* the restart itself (only a couple of filesystem ops), so any mtime this old can
|
||||||
|
* belong only to a dead holder.
|
||||||
|
*/
|
||||||
|
async function isRestartLockPathStale(path: string, now: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const info = await stat(path);
|
||||||
|
return now - info.mtimeMs >= RESTART_LOCK_STALE_MS;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return false; // Gone, not stale — the caller will re-contend.
|
||||||
|
}
|
||||||
|
return false; // Can't stat — treat as live and back off rather than reap.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path of the reclaim lock that serializes reaping of a crashed-holder mutex. */
|
||||||
|
function restartReclaimPath(mutexPath: string): string {
|
||||||
|
return `${mutexPath}.reclaim`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reap a registry mutex left behind by a process that CRASHED mid-transition —
|
||||||
|
* one whose file has aged past RESTART_LOCK_STALE_MS. Because the mutex is held
|
||||||
|
* only for a couple of filesystem ops (no sleeps, never across the restart), a
|
||||||
|
* mutex this old can only belong to a dead holder.
|
||||||
|
*
|
||||||
|
* The reap removes the dead mutex but never CREATES/holds it — acquisition stays
|
||||||
|
* the single `open('wx')` create in {@link acquireRestartMutex}, so exactly one
|
||||||
|
* contender wins ownership no matter how the reap and acquires interleave. The
|
||||||
|
* removal is made conditional by a dedicated reclaim lock: while it is held the
|
||||||
|
* dead mutex is stable (its dead holder will never touch it, and no other
|
||||||
|
* reclaimer can race), so re-reading it and removing it only if it is STILL stale
|
||||||
|
* is a true compare — a live holder's fresh mutex is never removed. This closes
|
||||||
|
* the reclaim race a content-blind rename-and-restore left open (a third
|
||||||
|
* contender slipping into the gap while a fresh mutex was moved aside).
|
||||||
|
*/
|
||||||
|
async function reclaimStaleRestartMutex(mutexPath: string): Promise<void> {
|
||||||
|
const reclaimPath = restartReclaimPath(mutexPath);
|
||||||
|
let handle: Awaited<ReturnType<typeof open>>;
|
||||||
|
try {
|
||||||
|
handle = await open(reclaimPath, 'wx');
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// Someone is already reclaiming. If their reclaim lock is itself stale by
|
||||||
|
// mtime, its holder crashed mid-reap (the lock spans only a stat + unlink,
|
||||||
|
// microseconds) — clear it so a later pass can retry. Otherwise a live
|
||||||
|
// reclaimer has it; back off. Either way we do not reap the mutex this pass.
|
||||||
|
if (await isRestartLockPathStale(reclaimPath, Date.now())) {
|
||||||
|
await unlink(reclaimPath).catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Re-check the mutex UNDER the reclaim lock and remove it only if it is STILL
|
||||||
|
// stale by mtime. A live holder's mutex is fresh and is left untouched; a dead
|
||||||
|
// holder's mutex is stable here (its holder is gone and no other reclaimer can
|
||||||
|
// race us), so this re-check is authoritative.
|
||||||
|
if (await isRestartLockPathStale(mutexPath, Date.now())) {
|
||||||
|
await unlink(mutexPath).catch(() => {});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
await unlink(reclaimPath).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire the registry mutex, BLOCKING (with brief back-offs) until held, and
|
||||||
|
* return a token-gated release. This is the single point of mutual exclusion for
|
||||||
|
* the restart lock: acquire, release, and stale/timeout takeover all run under it,
|
||||||
|
* so "read the lock, then mutate it" is atomic — no acquirer, releaser, or breaker
|
||||||
|
* can ever interleave with another. A mutex left by a crashed holder is reclaimed
|
||||||
|
* once it ages past the stale threshold.
|
||||||
|
*/
|
||||||
|
async function acquireRestartMutex(
|
||||||
|
mutexPath: string,
|
||||||
|
token: string,
|
||||||
|
): Promise<RestartGuard['release']> {
|
||||||
|
for (;;) {
|
||||||
|
let handle: Awaited<ReturnType<typeof open>>;
|
||||||
|
try {
|
||||||
|
handle = await open(mutexPath, 'wx');
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// Staleness is judged by mtime, not content, so a mutex that exists but has
|
||||||
|
// not yet had its token written (the open-before-write window) reads as live
|
||||||
|
// and is never wrongly reaped.
|
||||||
|
if (!(await isRestartLockPathStale(mutexPath, Date.now()))) {
|
||||||
|
// A live holder has it — it will be gone in microseconds. Back off briefly.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, RESTART_MUTEX_RETRY_MS));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await reclaimStaleRestartMutex(mutexPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// We created the mutex. Populate it with our token; if writing fails, clean up
|
||||||
|
// our own file so we never leak an empty mutex that a peer would have to reap.
|
||||||
|
try {
|
||||||
|
await handle.writeFile(formatRestartLockContent(token));
|
||||||
|
await handle.close();
|
||||||
|
} catch (err) {
|
||||||
|
await handle.close().catch(() => {});
|
||||||
|
await unlink(mutexPath).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return async (): Promise<void> => {
|
||||||
|
if ((await readRestartLockToken(mutexPath)) !== token) return;
|
||||||
|
await unlink(mutexPath).catch(() => {});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire the fleet restart lock, serializing concurrent `mosaic fleet restart`
|
||||||
|
* invocations across processes. Each restart tears the tmux holder (and the
|
||||||
|
* agent sessions inside it) down and back up; without this guard a re-entrant
|
||||||
|
* restart relaunches agents against a half-torn-down holder, which fails and
|
||||||
|
* tight-loops. A re-entrant caller waits for the in-flight restart to release
|
||||||
|
* the lock (clean shutdown settled) before proceeding, breaks a stale lock left
|
||||||
|
* by a crashed owner, and after RESTART_LOCK_MAX_WAIT_MS breaks the lock to
|
||||||
|
* avoid a permanent deadlock.
|
||||||
|
*
|
||||||
|
* Correctness rests on a single invariant: EVERY transition of the lock — taking
|
||||||
|
* a free lock, taking over a stale/timed-out one, and releasing — happens under
|
||||||
|
* the registry mutex. Because the check ("is the lock free / stale / fresh?") and
|
||||||
|
* the mutation that follows it both run while the mutex is held, they are atomic:
|
||||||
|
* no other acquirer, releaser, or breaker can slip in between. That is what makes
|
||||||
|
* takeover a true compare-and-swap rather than a content-blind clobber — a normal
|
||||||
|
* `open('wx')` acquirer cannot create a fresh lock in a gap, and the original
|
||||||
|
* owner's `release()` (also mutex-gated and token-checked) cannot drop a lock a
|
||||||
|
* breaker already took over. So no interleaving lets two restarts both own the
|
||||||
|
* lock and run concurrently.
|
||||||
|
*/
|
||||||
|
export async function acquireRestartLock(
|
||||||
|
mosaicHome: string,
|
||||||
|
sleepFn: SleepFn,
|
||||||
|
): Promise<RestartGuard> {
|
||||||
|
const token = randomUUID();
|
||||||
|
const lockPath = restartLockPath(mosaicHome);
|
||||||
|
const mutexPath = restartMutexPath(lockPath);
|
||||||
|
await mkdir(dirname(lockPath), { recursive: true });
|
||||||
|
const release = async (): Promise<void> => {
|
||||||
|
// Mutex-gated and token-gated: only remove the lock if it is still ours. If
|
||||||
|
// another caller took it over (after a stale/timeout break) the token no
|
||||||
|
// longer matches and we leave their lock intact.
|
||||||
|
const releaseMutex = await acquireRestartMutex(mutexPath, token);
|
||||||
|
try {
|
||||||
|
if ((await readRestartLockToken(lockPath)) === token) {
|
||||||
|
await unlink(lockPath).catch(() => {});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await releaseMutex();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const deadline = Date.now() + RESTART_LOCK_MAX_WAIT_MS;
|
||||||
|
for (;;) {
|
||||||
|
let owned = false;
|
||||||
|
const releaseMutex = await acquireRestartMutex(mutexPath, token);
|
||||||
|
try {
|
||||||
|
// Read and (if appropriate) mutate the lock atomically under the mutex.
|
||||||
|
let current: string | null = null;
|
||||||
|
let absent = false;
|
||||||
|
try {
|
||||||
|
current = await readFile(lockPath, 'utf8');
|
||||||
|
} catch (readErr) {
|
||||||
|
if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
absent = true;
|
||||||
|
} else {
|
||||||
|
current = null; // Unreadable/corrupt: treat as stale.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (absent) {
|
||||||
|
// Lock is free — take it.
|
||||||
|
await writeFile(lockPath, formatRestartLockContent(token));
|
||||||
|
owned = true;
|
||||||
|
} else {
|
||||||
|
const stale = current === null || isRestartLockContentStale(current, now);
|
||||||
|
const timedOut = now >= deadline;
|
||||||
|
if (stale || timedOut) {
|
||||||
|
process.stderr.write(
|
||||||
|
stale
|
||||||
|
? 'Breaking stale fleet restart lock.\n'
|
||||||
|
: `Timed out after ${RESTART_LOCK_MAX_WAIT_MS}ms waiting for the in-flight fleet ` +
|
||||||
|
'restart; breaking the lock.\n',
|
||||||
|
);
|
||||||
|
// Takeover is just an overwrite — safe because we hold the mutex, so no
|
||||||
|
// acquirer or releaser can touch the lock between our read and this write.
|
||||||
|
await writeFile(lockPath, formatRestartLockContent(token));
|
||||||
|
owned = true;
|
||||||
|
}
|
||||||
|
// else: a fresh restart owns it — wait below and re-evaluate.
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await releaseMutex();
|
||||||
|
}
|
||||||
|
if (owned) {
|
||||||
|
return { release };
|
||||||
|
}
|
||||||
|
await sleepFn(RESTART_LOCK_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the systemctl --user enable command for a given unit.
|
* Returns the systemctl --user enable command for a given unit.
|
||||||
* Used by the install auto-enable step to persist units across reboots.
|
* Used by the install auto-enable step to persist units across reboots.
|
||||||
@@ -1172,6 +1472,7 @@ export function isSendAccepted(capturedOutput: string): SendVerifyResult {
|
|||||||
|
|
||||||
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
||||||
const runner = deps.runner ?? runCommand;
|
const runner = deps.runner ?? runCommand;
|
||||||
|
const sleepFn = deps.sleepFn ?? defaultSleep;
|
||||||
const paths = resolveFleetPaths(deps.mosaicHome);
|
const paths = resolveFleetPaths(deps.mosaicHome);
|
||||||
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
||||||
|
|
||||||
@@ -1285,9 +1586,22 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.command(`${action} [agent]`)
|
.command(`${action} [agent]`)
|
||||||
.description(`${action} the fleet holder or one agent`)
|
.description(`${action} the fleet holder or one agent`)
|
||||||
.action(async (agent?: string) => {
|
.action(async (agent?: string) => {
|
||||||
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
const roster = await loadRosterForCommand(cmd);
|
const roster = await loadRosterForCommand(cmd);
|
||||||
if (agent) {
|
if (agent) {
|
||||||
getRosterAgent(roster, agent);
|
getRosterAgent(roster, agent);
|
||||||
|
// Single-agent restart is guarded too: it can race a full restart that
|
||||||
|
// is tearing the shared holder down.
|
||||||
|
if (action === 'restart') {
|
||||||
|
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||||
|
try {
|
||||||
|
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||||
|
} finally {
|
||||||
|
await guard.release();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1298,6 +1612,21 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === 'restart') {
|
||||||
|
// Serialize the holder+agents teardown/relaunch behind the restart lock
|
||||||
|
// so a re-entrant restart waits for clean shutdown before relaunching,
|
||||||
|
// instead of racing a half-torn-down holder into a tight loop.
|
||||||
|
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||||
|
try {
|
||||||
|
await runChecked(runner, buildFleetServiceCommand(action));
|
||||||
|
for (const rosterAgent of roster.agents) {
|
||||||
|
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await guard.release();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
await runChecked(runner, buildFleetServiceCommand(action));
|
await runChecked(runner, buildFleetServiceCommand(action));
|
||||||
for (const rosterAgent of roster.agents) {
|
for (const rosterAgent of roster.agents) {
|
||||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||||
|
|||||||
222
tools/install-next-lane.test.sh
Executable file
222
tools/install-next-lane.test.sh
Executable file
@@ -0,0 +1,222 @@
|
|||||||
|
#!/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"
|
||||||
357
tools/install.sh
357
tools/install.sh
@@ -16,6 +16,14 @@
|
|||||||
# --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
|
||||||
|
# registry @latest. Zero registry writes — packs local
|
||||||
|
# tarballs and installs them globally. Use to test a branch
|
||||||
|
# end-to-end before cutting a release.
|
||||||
# --yes Accept all defaults; headless/non-interactive install
|
# --yes Accept all defaults; headless/non-interactive install
|
||||||
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
|
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
|
||||||
# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line
|
# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line
|
||||||
@@ -27,6 +35,8 @@
|
|||||||
# 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_ASSUME_YES — equivalent to --yes (set to 1)
|
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
@@ -43,19 +53,42 @@ FLAG_CLI=true
|
|||||||
FLAG_NO_AUTO_LAUNCH=false
|
FLAG_NO_AUTO_LAUNCH=false
|
||||||
FLAG_YES=false
|
FLAG_YES=false
|
||||||
FLAG_UNINSTALL=false
|
FLAG_UNINSTALL=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
|
||||||
FLAG_YES=true
|
FLAG_YES=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# MOSAIC_DEV env var acts the same as --dev
|
||||||
|
if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
|
||||||
|
FLAG_DEV=true
|
||||||
|
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}"; shift 2 ;;
|
--ref) GIT_REF="${2:-main}"; GIT_REF_EXPLICIT=true; shift 2 ;;
|
||||||
|
--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 ;;
|
||||||
@@ -63,15 +96,52 @@ 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"
|
||||||
|
|
||||||
|
# In dev (build-from-source) mode the gateway is installed globally from a
|
||||||
|
# locally-built tarball. Tell the wizard / gateway-config stage NOT to overwrite
|
||||||
|
# it with the registry @latest build (honored by gatewayConfigStage).
|
||||||
|
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||||
|
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Shared monorepo checkout (populated on demand by ensure_monorepo).
|
||||||
|
WORK_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.
|
||||||
@@ -135,7 +205,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="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
|
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
|
||||||
fi
|
fi
|
||||||
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
|
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
|
||||||
cp "$backup" "$dest"
|
cp "$backup" "$dest"
|
||||||
@@ -191,6 +261,22 @@ 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() {
|
||||||
@@ -213,10 +299,43 @@ 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];
|
||||||
@@ -239,6 +358,142 @@ framework_version() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Download + extract the monorepo archive at $GIT_REF exactly once per run.
|
||||||
|
# Sets the script-level EXTRACTED_DIR to the repo root. Reused by both the
|
||||||
|
# framework install (Part 1) and the dev build-from-source path (Part 2).
|
||||||
|
ensure_monorepo() {
|
||||||
|
if [[ -n "$EXTRACTED_DIR" ]] && [[ -d "$EXTRACTED_DIR" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_cmd tar
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")"
|
||||||
|
# shellcheck disable=SC2317
|
||||||
|
cleanup_work() { [[ -n "$WORK_DIR" ]] && rm -rf "$WORK_DIR"; }
|
||||||
|
trap cleanup_work EXIT
|
||||||
|
|
||||||
|
info "Downloading source from ${GIT_REF}…"
|
||||||
|
if command -v curl &>/dev/null; then
|
||||||
|
curl -fsSL "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
|
||||||
|
elif command -v wget &>/dev/null; then
|
||||||
|
wget -qO- "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
|
||||||
|
else
|
||||||
|
fail "curl or wget required to download source."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gitea archives extract to <repo-name>/ inside the work dir
|
||||||
|
EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
|
||||||
|
if [[ -z "$EXTRACTED_DIR" ]] || [[ ! -d "$EXTRACTED_DIR" ]]; then
|
||||||
|
fail "Could not locate extracted source in archive."
|
||||||
|
ls -la "$WORK_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build @mosaicstack/mosaic + @mosaicstack/gateway from source and install both
|
||||||
|
# globally from locally-packed tarballs. ZERO registry writes. Workspace deps
|
||||||
|
# (brain/config/db/…) are pulled from the registry at the versions pinned in
|
||||||
|
# each package.json — `pnpm pack` rewrites `workspace:*` to those versions.
|
||||||
|
install_cli_from_source() {
|
||||||
|
local src="$EXTRACTED_DIR"
|
||||||
|
local out_dir="$WORK_DIR/dist-tarballs"
|
||||||
|
mkdir -p "$out_dir"
|
||||||
|
|
||||||
|
# pnpm via corepack (ships with Node >= 16.9; required by Node >= 20 preflight).
|
||||||
|
# Pin to the repo's packageManager version so the build matches CI. Surface
|
||||||
|
# corepack failures so the fresh-machine case gives an actionable error
|
||||||
|
# instead of a bare "command not found".
|
||||||
|
if ! command -v pnpm &>/dev/null; then
|
||||||
|
info "Activating pnpm via corepack…"
|
||||||
|
corepack enable 2>&1 | sed 's/^/ /' || warn "corepack enable failed — pnpm may need manual install."
|
||||||
|
corepack prepare pnpm@10.6.2 --activate 2>&1 | sed 's/^/ /' \
|
||||||
|
|| warn "corepack prepare failed — pnpm may need manual install."
|
||||||
|
fi
|
||||||
|
if ! command -v pnpm &>/dev/null; then
|
||||||
|
fail "pnpm not available after corepack activation."
|
||||||
|
echo " Install pnpm manually (https://pnpm.io/installation) and re-run with --dev."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Installing workspace dependencies (pnpm install)…"
|
||||||
|
( cd "$src" && pnpm install ) 2>&1 | sed 's/^/ /'
|
||||||
|
|
||||||
|
info "Building CLI + gateway from source…"
|
||||||
|
( cd "$src" && pnpm --filter "@mosaicstack/mosaic..." --filter "@mosaicstack/gateway..." run build ) 2>&1 | sed 's/^/ /'
|
||||||
|
|
||||||
|
info "Packing local tarballs…"
|
||||||
|
( cd "$src/packages/mosaic" && 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
|
||||||
|
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')"
|
||||||
|
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')"
|
||||||
|
|
||||||
|
if [[ ! -f "$cli_tgz" ]]; then
|
||||||
|
fail "CLI tarball was not produced by pnpm pack."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$gw_tgz" ]]; then
|
||||||
|
fail "Gateway tarball was not produced by pnpm pack."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gateway first so it is present globally before the CLI's wizard runs (which
|
||||||
|
# skips its own gateway install via MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1).
|
||||||
|
info "Installing gateway from source tarball (global)…"
|
||||||
|
npm install -g "$gw_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'
|
||||||
|
|
||||||
|
info "Installing CLI from source tarball (global)…"
|
||||||
|
npm install -g "$cli_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'
|
||||||
|
|
||||||
|
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
|
||||||
@@ -272,7 +527,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
|
|||||||
else
|
else
|
||||||
dim " Installed: (none)"
|
dim " Installed: (none)"
|
||||||
fi
|
fi
|
||||||
dim " Source: ${REPO_BASE} (ref: ${GIT_REF})"
|
dim " Source: ${REPO_BASE} ($(source_ref_details))"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [[ "$FLAG_CHECK" == "true" ]]; then
|
if [[ "$FLAG_CHECK" == "true" ]]; then
|
||||||
@@ -282,25 +537,8 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
|
|||||||
warn "Framework not installed."
|
warn "Framework not installed."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Download repo archive and extract framework
|
# Download repo archive and extract framework (shared with the dev build)
|
||||||
require_cmd tar
|
ensure_monorepo
|
||||||
|
|
||||||
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")"
|
|
||||||
cleanup_work() { rm -rf "$WORK_DIR"; }
|
|
||||||
trap cleanup_work EXIT
|
|
||||||
|
|
||||||
info "Downloading framework from ${GIT_REF}…"
|
|
||||||
if command -v curl &>/dev/null; then
|
|
||||||
curl -fsSL "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
|
|
||||||
elif command -v wget &>/dev/null; then
|
|
||||||
wget -qO- "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
|
|
||||||
else
|
|
||||||
fail "curl or wget required to download framework."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Gitea archives extract to <repo-name>/ inside the work dir
|
|
||||||
EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
|
|
||||||
FRAMEWORK_SRC="$EXTRACTED_DIR/packages/mosaic/framework"
|
FRAMEWORK_SRC="$EXTRACTED_DIR/packages/mosaic/framework"
|
||||||
|
|
||||||
if [[ ! -d "$FRAMEWORK_SRC" ]]; then
|
if [[ ! -d "$FRAMEWORK_SRC" ]]; then
|
||||||
@@ -356,7 +594,15 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
CURRENT="$(installed_cli_version)"
|
CURRENT="$(installed_cli_version)"
|
||||||
LATEST="$(latest_cli_version)"
|
NEXT_GATEWAY=""
|
||||||
|
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||||
|
LATEST=""
|
||||||
|
elif is_next_registry_lane; then
|
||||||
|
LATEST="$(next_cli_version)"
|
||||||
|
NEXT_GATEWAY="$(next_gateway_version)"
|
||||||
|
else
|
||||||
|
LATEST="$(latest_cli_version)"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "$CURRENT" ]]; then
|
if [[ -n "$CURRENT" ]]; then
|
||||||
dim " Installed: ${CLI_PKG}@${CURRENT}"
|
dim " Installed: ${CLI_PKG}@${CURRENT}"
|
||||||
@@ -364,7 +610,21 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
|||||||
dim " Installed: (none)"
|
dim " Installed: (none)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$LATEST" ]]; then
|
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||||
|
dim " Source: ${REPO_BASE} ($(source_ref_details), 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
|
||||||
dim " Latest: ${CLI_PKG}@${LATEST}"
|
dim " Latest: ${CLI_PKG}@${LATEST}"
|
||||||
else
|
else
|
||||||
dim " Latest: (registry unreachable)"
|
dim " Latest: (registry unreachable)"
|
||||||
@@ -372,7 +632,15 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [[ "$FLAG_CHECK" == "true" ]]; then
|
if [[ "$FLAG_CHECK" == "true" ]]; then
|
||||||
if [[ -z "$LATEST" ]]; then
|
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||||
|
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
|
||||||
warn "Could not reach registry."
|
warn "Could not reach registry."
|
||||||
elif [[ -z "$CURRENT" ]]; then
|
elif [[ -z "$CURRENT" ]]; then
|
||||||
warn "Not installed."
|
warn "Not installed."
|
||||||
@@ -383,6 +651,33 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
|||||||
else
|
else
|
||||||
ok "Up to date (or ahead of registry)."
|
ok "Up to date (or ahead of registry)."
|
||||||
fi
|
fi
|
||||||
|
elif [[ "$FLAG_DEV" == "true" ]]; then
|
||||||
|
info "Dev mode — building CLI + gateway from source at ref ${GIT_REF}…"
|
||||||
|
ensure_monorepo
|
||||||
|
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
|
||||||
|
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
|
||||||
else
|
else
|
||||||
if [[ -z "$LATEST" ]]; then
|
if [[ -z "$LATEST" ]]; then
|
||||||
warn "Could not reach registry at $REGISTRY — skipping npm CLI."
|
warn "Could not reach registry at $REGISTRY — skipping npm CLI."
|
||||||
@@ -486,7 +781,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="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
|
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
|
||||||
if [[ -n "$backup_path" ]]; then
|
if [[ -n "$backup_path" ]]; then
|
||||||
backup_val="\"$backup_path\""
|
backup_val="\"$backup_path\""
|
||||||
else
|
else
|
||||||
@@ -511,7 +806,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
|||||||
NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
|
NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
node -e "
|
if 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];
|
||||||
@@ -536,9 +831,11 @@ 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 \
|
"$RUNTIME_COPIES" 2>/dev/null; then
|
||||||
&& ok "Install manifest written: $MANIFEST_PATH" \
|
ok "Install manifest written: $MANIFEST_PATH"
|
||||||
|| warn "Could not write install manifest (non-fatal)"
|
else
|
||||||
|
warn "Could not write install manifest (non-fatal)"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
ok "Done."
|
ok "Done."
|
||||||
|
|||||||
Reference in New Issue
Block a user