Compare commits

..

2 Commits

Author SHA1 Message Date
Jarvis
6e37ab00b9 feat(#462): add federation list verb
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/pr/ci Pipeline was successful
2026-06-24 18:24:57 -05:00
c739256a2c feat(#462): add federation scope enforcement service (#672)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 23:22:46 +00:00
11 changed files with 1551 additions and 141 deletions

View File

@@ -7,10 +7,12 @@ import { FederationController } from './federation.controller.js';
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
import { GrantsService } from './grants.service.js';
import { FederationClientService, QuerySourceService } from './client/index.js';
import { FederationAuthGuard } from './server/index.js';
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
import { ListController } from './server/verbs/list.controller.js';
import { FederationListQueryService } from './server/verbs/list-query.service.js';
@Module({
controllers: [EnrollmentController, FederationController, CapabilitiesController],
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
providers: [
AdminGuard,
CaService,
@@ -19,6 +21,8 @@ import { FederationAuthGuard } from './server/index.js';
FederationClientService,
QuerySourceService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
exports: [
CaService,
@@ -27,6 +31,8 @@ import { FederationAuthGuard } from './server/index.js';
FederationClientService,
QuerySourceService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
})
export class FederationModule {}

View File

@@ -0,0 +1,324 @@
/**
* Unit tests for FederationScopeService (FED-M3-04).
*
* Coverage:
* - resource allowlist deny
* - excluded resource deny
* - invalid scope deny
* - invalid requested limit deny
* - native RBAC deny as subjectUserId
* - scope/native filter intersection for personal and team rows
* - native RBAC personal deny wins over scope include_personal allow/default
* - max_rows_per_query cap
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FederationScopeService, type FederationNativeRbacEvaluator } from '../scope.service.js';
import type { FederationContext } from '../federation-context.js';
const GRANT_ID = 'grant-1';
const PEER_ID = 'peer-1';
const SUBJECT_USER_ID = 'user-1';
function makeContext(scope: Record<string, unknown>): FederationContext {
return {
grantId: GRANT_ID,
peerId: PEER_ID,
subjectUserId: SUBJECT_USER_ID,
scope,
};
}
function makeNativeRbac(
result: Awaited<ReturnType<FederationNativeRbacEvaluator['evaluateReadAccess']>>,
): FederationNativeRbacEvaluator {
return {
evaluateReadAccess: vi.fn().mockResolvedValue(result),
};
}
describe('FederationScopeService', () => {
let service: FederationScopeService;
beforeEach(() => {
service = new FederationScopeService();
});
it('allows a granted resource and returns a capped query filter', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
});
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
filters: { tasks: { include_teams: ['team-1', 'team-3'], include_personal: true } },
max_rows_per_query: 50,
}),
resource: 'tasks',
requestedLimit: 500,
nativeRbac,
});
expect(result).toEqual({
allowed: true,
filter: {
resource: 'tasks',
subjectUserId: SUBJECT_USER_ID,
includePersonal: true,
teamIds: ['team-1'],
limit: 50,
maxRowsPerQuery: 50,
},
});
expect(nativeRbac.evaluateReadAccess).toHaveBeenCalledWith({
grantId: GRANT_ID,
peerId: PEER_ID,
subjectUserId: SUBJECT_USER_ID,
resource: 'tasks',
});
});
it('defaults absent resource filters to native RBAC personal and team visibility', async () => {
const result = await service.evaluateAccess({
context: makeContext({ resources: ['notes'], max_rows_per_query: 100 }),
resource: 'notes',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: true,
teamIds: ['team-1', 'team-2'],
limit: 100,
},
});
});
it('honors include_personal false even when native RBAC allows personal rows', async () => {
const result = await service.evaluateAccess({
context: makeContext({
resources: ['memory'],
filters: { memory: { include_personal: false } },
max_rows_per_query: 25,
}),
resource: 'memory',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: false,
teamIds: [],
},
});
});
it('does not leak personal rows when scope allows personal but native RBAC denies personal', async () => {
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
filters: { tasks: { include_personal: true } },
max_rows_per_query: 25,
}),
resource: 'tasks',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: false, teamIds: ['team-1'] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: false,
teamIds: ['team-1'],
},
});
});
it('does not widen native RBAC when scope includes teams the user cannot access', async () => {
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
filters: { tasks: { include_teams: ['team-2'], include_personal: false } },
max_rows_per_query: 25,
}),
resource: 'tasks',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1'] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: false,
teamIds: [],
},
});
});
it('denies invalid grant scope before RBAC evaluation', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: [], max_rows_per_query: 100 }),
resource: 'tasks',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'invalid_scope',
stage: 'scope_parse',
statusCode: 400,
grantId: GRANT_ID,
subjectUserId: SUBJECT_USER_ID,
resource: 'tasks',
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies unsupported resource names before RBAC evaluation', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'unknown_resource',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'invalid_resource',
stage: 'resource_allowlist',
statusCode: 403,
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies resources explicitly present in excluded_resources before allowlist miss', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
excluded_resources: ['credentials'],
max_rows_per_query: 100,
}),
resource: 'credentials',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'resource_excluded',
stage: 'resource_exclusion',
statusCode: 403,
resource: 'credentials',
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies supported resources that are not granted by scope', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'notes',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'resource_not_granted',
stage: 'resource_allowlist',
statusCode: 403,
resource: 'notes',
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies invalid requested row limits before RBAC evaluation', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'tasks',
requestedLimit: 0,
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'invalid_limit',
stage: 'row_cap',
statusCode: 400,
details: { requestedLimit: 0 },
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies when native RBAC rejects subjectUserId access to the resource', async () => {
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'tasks',
nativeRbac: makeNativeRbac({
allowed: false,
reason: 'read:tasks denied',
details: { permission: 'tasks:read' },
}),
});
expect(result).toEqual({
allowed: false,
deny: {
code: 'native_rbac_denied',
stage: 'native_rbac',
statusCode: 403,
message: 'read:tasks denied',
grantId: GRANT_ID,
peerId: PEER_ID,
subjectUserId: SUBJECT_USER_ID,
resource: 'tasks',
details: { permission: 'tasks:read' },
},
});
});
});

View File

@@ -10,4 +10,22 @@
*/
export { FederationAuthGuard } from './federation-auth.guard.js';
export { FederationScopeService } from './scope.service.js';
export type { FederationContext } from './federation-context.js';
export type {
FederationNativeRbacAccess,
FederationNativeRbacAllowedResult,
FederationNativeRbacDeniedResult,
FederationNativeRbacEvaluator,
FederationNativeRbacRequest,
FederationNativeRbacResult,
FederationScopeAllowedResult,
FederationScopeDeniedResult,
FederationScopeDenyCode,
FederationScopeDenyDetails,
FederationScopeDenyReason,
FederationScopeDenyStage,
FederationScopeEvaluationInput,
FederationScopeEvaluationResult,
FederationScopeQueryFilter,
} from './scope.service.js';

View File

@@ -0,0 +1,272 @@
/**
* FederationScopeService — M3 server-side scope enforcement pipeline.
*
* Pure trust-boundary service: it validates the grant scope, asks an injected
* native RBAC evaluator what the subject user can read locally, intersects that
* answer with the federation scope filters, and returns a query filter for the
* verb controllers. The service performs no DB calls directly.
*/
import { Injectable } from '@nestjs/common';
import {
FEDERATION_RESOURCE_VALUES,
type FederationResource,
FederationScopeError,
parseFederationScope,
} from '../scope-schema.js';
import type { FederationContext } from './federation-context.js';
const federationResourceSet: ReadonlySet<string> = new Set<string>(FEDERATION_RESOURCE_VALUES);
export type FederationScopeDenyStage =
| 'scope_parse'
| 'resource_allowlist'
| 'resource_exclusion'
| 'native_rbac'
| 'row_cap';
export type FederationScopeDenyCode =
| 'invalid_scope'
| 'invalid_resource'
| 'resource_not_granted'
| 'resource_excluded'
| 'native_rbac_denied'
| 'invalid_limit';
export type FederationScopeDenyStatus = 400 | 403;
export interface FederationScopeDenyDetails {
readonly [key: string]: string | number | boolean | readonly string[];
}
export interface FederationScopeDenyReason {
readonly code: FederationScopeDenyCode;
readonly stage: FederationScopeDenyStage;
readonly statusCode: FederationScopeDenyStatus;
readonly message: string;
readonly grantId: string;
readonly peerId: string;
readonly subjectUserId: string;
readonly resource: string;
readonly details?: FederationScopeDenyDetails;
}
export interface FederationNativeRbacRequest {
readonly grantId: string;
readonly peerId: string;
readonly subjectUserId: string;
readonly resource: FederationResource;
}
export interface FederationNativeRbacAccess {
/** Whether this user may read personal rows for this resource. */
readonly includePersonal: boolean;
/** Team IDs this user may read for this resource under native RBAC. */
readonly teamIds: readonly string[];
}
export interface FederationNativeRbacAllowedResult {
readonly allowed: true;
readonly access: FederationNativeRbacAccess;
}
export interface FederationNativeRbacDeniedResult {
readonly allowed: false;
readonly reason?: string;
readonly details?: FederationScopeDenyDetails;
}
export type FederationNativeRbacResult =
| FederationNativeRbacAllowedResult
| FederationNativeRbacDeniedResult;
export interface FederationNativeRbacEvaluator {
evaluateReadAccess(request: FederationNativeRbacRequest): Promise<FederationNativeRbacResult>;
}
export interface FederationScopeEvaluationInput {
readonly context: FederationContext;
readonly resource: string;
readonly requestedLimit?: number;
readonly nativeRbac: FederationNativeRbacEvaluator;
}
export interface FederationScopeQueryFilter {
readonly resource: FederationResource;
readonly subjectUserId: string;
readonly includePersonal: boolean;
readonly teamIds: readonly string[];
readonly limit: number;
readonly maxRowsPerQuery: number;
}
export interface FederationScopeAllowedResult {
readonly allowed: true;
readonly filter: FederationScopeQueryFilter;
}
export interface FederationScopeDeniedResult {
readonly allowed: false;
readonly deny: FederationScopeDenyReason;
}
export type FederationScopeEvaluationResult =
| FederationScopeAllowedResult
| FederationScopeDeniedResult;
function isFederationResource(resource: string): resource is FederationResource {
return federationResourceSet.has(resource);
}
function uniqueStrings(values: readonly string[]): readonly string[] {
return Array.from(new Set<string>(values));
}
function intersectTeamIds(
nativeTeamIds: readonly string[],
scopedTeamIds: readonly string[] | undefined,
): readonly string[] {
const uniqueNativeTeamIds = uniqueStrings(nativeTeamIds);
if (scopedTeamIds === undefined) {
return uniqueNativeTeamIds;
}
const nativeSet = new Set<string>(uniqueNativeTeamIds);
return uniqueStrings(scopedTeamIds).filter((teamId: string): boolean => nativeSet.has(teamId));
}
function makeDenyReason(params: {
readonly code: FederationScopeDenyCode;
readonly stage: FederationScopeDenyStage;
readonly statusCode?: FederationScopeDenyStatus;
readonly message: string;
readonly context: FederationContext;
readonly resource: string;
readonly details?: FederationScopeDenyDetails;
}): FederationScopeDeniedResult {
return {
allowed: false,
deny: {
code: params.code,
stage: params.stage,
statusCode: params.statusCode ?? 403,
message: params.message,
grantId: params.context.grantId,
peerId: params.context.peerId,
subjectUserId: params.context.subjectUserId,
resource: params.resource,
...(params.details !== undefined ? { details: params.details } : {}),
},
};
}
@Injectable()
export class FederationScopeService {
async evaluateAccess(
input: FederationScopeEvaluationInput,
): Promise<FederationScopeEvaluationResult> {
const { context, resource, requestedLimit, nativeRbac } = input;
let scope: ReturnType<typeof parseFederationScope>;
try {
scope = parseFederationScope(context.scope);
} catch (error: unknown) {
const message =
error instanceof FederationScopeError
? 'Federation grant scope is invalid'
: 'Federation grant scope could not be parsed';
const details = error instanceof Error ? { reason: error.message } : undefined;
return makeDenyReason({
code: 'invalid_scope',
stage: 'scope_parse',
statusCode: 400,
message,
context,
resource,
...(details !== undefined ? { details } : {}),
});
}
if (!isFederationResource(resource)) {
return makeDenyReason({
code: 'invalid_resource',
stage: 'resource_allowlist',
message: 'Requested federation resource is not supported',
context,
resource,
details: { supportedResources: FEDERATION_RESOURCE_VALUES },
});
}
if (scope.excluded_resources.includes(resource)) {
return makeDenyReason({
code: 'resource_excluded',
stage: 'resource_exclusion',
message: 'Requested federation resource is explicitly excluded by grant scope',
context,
resource,
});
}
if (!scope.resources.includes(resource)) {
return makeDenyReason({
code: 'resource_not_granted',
stage: 'resource_allowlist',
message: 'Requested federation resource is not granted by scope',
context,
resource,
details: { grantedResources: scope.resources },
});
}
if (requestedLimit !== undefined && (!Number.isInteger(requestedLimit) || requestedLimit < 1)) {
return makeDenyReason({
code: 'invalid_limit',
stage: 'row_cap',
statusCode: 400,
message: 'Requested row limit must be a positive integer',
context,
resource,
details: { requestedLimit },
});
}
const nativeResult = await nativeRbac.evaluateReadAccess({
grantId: context.grantId,
peerId: context.peerId,
subjectUserId: context.subjectUserId,
resource,
});
if (!nativeResult.allowed) {
return makeDenyReason({
code: 'native_rbac_denied',
stage: 'native_rbac',
message: nativeResult.reason ?? 'Subject user is not allowed to read this resource',
context,
resource,
...(nativeResult.details !== undefined ? { details: nativeResult.details } : {}),
});
}
const scopeFilter = scope.filters?.[resource];
const includePersonal =
Boolean(scopeFilter?.include_personal ?? true) && nativeResult.access.includePersonal;
const teamIds = intersectTeamIds(nativeResult.access.teamIds, scopeFilter?.include_teams);
const limit = Math.min(requestedLimit ?? scope.max_rows_per_query, scope.max_rows_per_query);
return {
allowed: true,
filter: {
resource,
subjectUserId: context.subjectUserId,
includePersonal,
teamIds,
limit,
maxRowsPerQuery: scope.max_rows_per_query,
},
};
}
}

View File

@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest';
import type { Db } from '@mosaicstack/db';
import type { FederationScopeQueryFilter } from '../../scope.service.js';
import { FederationListQueryService } from '../list-query.service.js';
const TASK_FILTER: FederationScopeQueryFilter = {
resource: 'tasks',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: [],
limit: 2,
maxRowsPerQuery: 2,
};
function makeService() {
return new FederationListQueryService({} as Db);
}
function stubRows(
service: FederationListQueryService,
...pages: Array<Array<Record<string, unknown>>>
) {
const mock = vi.fn();
for (const page of pages) {
mock.mockResolvedValueOnce(page);
}
(
service as unknown as {
listAllRows: (
_filter: FederationScopeQueryFilter,
_rowLimit: number,
_cursor: unknown,
) => Promise<Array<Record<string, unknown>>>;
}
).listAllRows = mock;
return mock;
}
describe('FederationListQueryService', () => {
it('denies sensitive resources in native RBAC for M3 list 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 list 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('applies the scope row cap and returns an opaque next cursor when truncated', async () => {
const service = makeService();
const listAllRows = stubRows(
service,
[
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') },
],
[{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
);
const firstPage = await service.list({ filter: TASK_FILTER });
expect(firstPage).toEqual({
items: [
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
],
truncated: true,
nextCursor: expect.any(String),
});
expect(listAllRows).toHaveBeenNthCalledWith(1, TASK_FILTER, 3, undefined);
const secondPage = await service.list({ filter: TASK_FILTER, cursor: firstPage.nextCursor });
expect(secondPage).toEqual({
items: [{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
truncated: false,
});
expect(listAllRows).toHaveBeenNthCalledWith(
2,
TASK_FILTER,
3,
expect.objectContaining({ id: '2' }),
);
});
it('rejects invalid cursors instead of falling back to the first page', async () => {
const service = makeService();
stubRows(service, [{ id: '1' }]);
await expect(service.list({ filter: TASK_FILTER, cursor: 'not-base64-json' })).rejects.toThrow(
'Invalid federation list cursor',
);
});
});

View File

@@ -0,0 +1,170 @@
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 { ListController } from '../list.controller.js';
import type { FederationListQueryResult } from '../list-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: 10,
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?: FederationListQueryResult;
}) {
const scope = {
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
};
const query = {
evaluateReadAccess: vi.fn(),
list: vi.fn().mockResolvedValue(
opts?.queryResult ?? {
items: [
{
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
},
],
truncated: false,
},
),
};
return {
controller: new ListController(scope as never, query as never),
scope,
query,
};
}
describe('ListController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('declares POST /api/federation/v1/list/:resource protected only by FederationAuthGuard', () => {
expect(Reflect.getMetadata('path', ListController)).toBe('api/federation/v1/list');
expect(Reflect.getMetadata('path', ListController.prototype.list)).toBe(':resource');
expect(Reflect.getMetadata('method', ListController.prototype.list)).toBe(RequestMethod.POST);
expect(Reflect.getMetadata('__guards__', ListController)).toEqual([FederationAuthGuard]);
});
it('runs AuthGuard context through ScopeService and returns local-source tagged rows', async () => {
const { controller, scope, query } = makeController();
const response = await controller.list('tasks', makeRequest(), { limit: 10 });
expect(scope.evaluateAccess).toHaveBeenCalledWith({
context: FEDERATION_CONTEXT,
resource: 'tasks',
requestedLimit: 10,
nativeRbac: query,
});
expect(query.list).toHaveBeenCalledWith({ filter: TASK_FILTER, cursor: undefined });
expect(response).toEqual({
items: [
{
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
_source: 'local',
},
],
});
});
it('preserves pagination metadata when row cap truncates the query layer result', async () => {
const { controller } = makeController({
queryResult: {
items: [{ id: 'task-1' }],
nextCursor: 'cursor-2',
truncated: true,
},
});
const response = await controller.list('tasks', makeRequest(), { cursor: 'cursor-1' });
expect(response).toEqual({
items: [{ id: 'task-1', _source: 'local' }],
nextCursor: 'cursor-2',
_truncated: true,
});
});
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.list('credentials', makeRequest(), {})).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is explicitly excluded by grant scope',
},
},
status: 403,
});
expect(query.list).not.toHaveBeenCalled();
});
it('rejects malformed request body fields before querying storage', async () => {
const { controller, scope, query } = makeController();
await expect(controller.list('tasks', makeRequest(), { cursor: 123 })).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
await expect(controller.list('tasks', makeRequest(), { limit: false })).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
await expect(controller.list('tasks', makeRequest(), { limit: 'abc' })).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.list).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,365 @@
/**
* Federation list query layer (FED-M3-05).
*
* Read-only DB adapter used by ListController 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,
desc,
eq,
inArray,
insights,
isNotNull,
lt,
missionTasks,
missions,
or,
preferences,
projects,
tasks,
teamMembers,
type Db,
} from '@mosaicstack/db';
import type {
FederationNativeRbacEvaluator,
FederationNativeRbacRequest,
FederationNativeRbacResult,
FederationScopeQueryFilter,
} from '../scope.service.js';
import { DB } from '../../../database/database.module.js';
export interface FederationListQueryRequest {
readonly filter: FederationScopeQueryFilter;
readonly cursor?: string;
}
export interface FederationListQueryResult<T extends object = Record<string, unknown>> {
readonly items: T[];
readonly nextCursor?: string;
readonly truncated: boolean;
}
type RowObject = Record<string, unknown>;
interface KeysetCursor {
readonly createdAt: Date;
readonly id: string;
}
function encodeCursor(row: RowObject): string | undefined {
const createdAt = row['createdAt'];
const id = row['id'];
if (!(createdAt instanceof Date) || typeof id !== 'string') {
return undefined;
}
return Buffer.from(JSON.stringify({ createdAt: createdAt.toISOString(), id }), 'utf8').toString(
'base64url',
);
}
function decodeCursor(cursor: string | undefined): KeysetCursor | undefined {
if (cursor === undefined) {
return undefined;
}
try {
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('cursor must be an object');
}
const { createdAt, id } = parsed as { createdAt?: unknown; id?: unknown };
if (typeof createdAt !== 'string' || typeof id !== 'string' || id.length === 0) {
throw new Error('cursor is missing createdAt or id');
}
const date = new Date(createdAt);
if (Number.isNaN(date.getTime())) {
throw new Error('cursor createdAt is invalid');
}
return { createdAt: date, id };
} catch {
throw new Error('Invalid federation list cursor');
}
}
function paginate<T extends RowObject>(rows: T[], limit: number): FederationListQueryResult<T> {
const page = rows.slice(0, limit);
const hasMore = rows.length > limit;
const nextCursor = hasMore ? encodeCursor(page[page.length - 1] ?? {}) : undefined;
return {
items: page,
truncated: hasMore,
...(nextCursor !== undefined ? { nextCursor } : {}),
};
}
function sortRows(rows: RowObject[]): RowObject[] {
return [...rows].sort((a, b) => {
const aTime = a['createdAt'] instanceof Date ? a['createdAt'].getTime() : 0;
const bTime = b['createdAt'] instanceof Date ? b['createdAt'].getTime() : 0;
if (aTime !== bTime) {
return bTime - aTime;
}
return String(b['id'] ?? '').localeCompare(String(a['id'] ?? ''));
});
}
@Injectable()
export class FederationListQueryService 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 list 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 list<T extends RowObject = RowObject>(
request: FederationListQueryRequest,
): Promise<FederationListQueryResult<T>> {
const cursor = decodeCursor(request.cursor);
const rows = await this.listAllRows(request.filter, request.filter.limit + 1, cursor);
return paginate(rows as T[], request.filter.limit);
}
private async listAllRows(
filter: FederationScopeQueryFilter,
rowLimit: number,
cursor: KeysetCursor | undefined,
): Promise<RowObject[]> {
switch (filter.resource) {
case 'tasks':
return this.listTasks(filter, rowLimit, cursor);
case 'notes':
return this.listNotes(filter, rowLimit, cursor);
case 'memory':
return this.listMemory(filter, rowLimit, cursor);
case 'credentials':
case 'api_keys':
return [];
}
}
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) {
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 listTasks(
filter: FederationScopeQueryFilter,
rowLimit: number,
cursor: KeysetCursor | undefined,
): Promise<RowObject[]> {
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
const clauses = [];
if (projectIds.length > 0) {
clauses.push(inArray(tasks.projectId, projectIds));
}
if (missionIds.length > 0) {
clauses.push(inArray(tasks.missionId, missionIds));
}
if (clauses.length === 0) {
return [];
}
const scopeClause = clauses.length === 1 ? clauses[0] : or(...clauses);
const cursorClause = cursor
? or(
lt(tasks.createdAt, cursor.createdAt),
and(eq(tasks.createdAt, cursor.createdAt), lt(tasks.id, cursor.id)),
)
: undefined;
const rows = 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(and(scopeClause, cursorClause))
.orderBy(desc(tasks.createdAt), desc(tasks.id))
.limit(rowLimit);
return sortRows(rows as RowObject[]);
}
private async listNotes(
filter: FederationScopeQueryFilter,
rowLimit: number,
cursor: KeysetCursor | undefined,
): Promise<RowObject[]> {
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
const clauses = [];
if (filter.includePersonal) {
clauses.push(eq(missionTasks.userId, filter.subjectUserId));
}
if (missionIds.length > 0) {
clauses.push(inArray(missionTasks.missionId, missionIds));
}
if (clauses.length === 0) {
return [];
}
const scopeClause = clauses.length === 1 ? clauses[0] : or(...clauses);
const cursorClause = cursor
? or(
lt(missionTasks.createdAt, cursor.createdAt),
and(eq(missionTasks.createdAt, cursor.createdAt), lt(missionTasks.id, cursor.id)),
)
: undefined;
const rows = await this.db
.select({
id: missionTasks.id,
missionId: missionTasks.missionId,
taskId: missionTasks.taskId,
status: missionTasks.status,
content: missionTasks.notes,
createdAt: missionTasks.createdAt,
updatedAt: missionTasks.updatedAt,
})
.from(missionTasks)
.where(and(scopeClause, cursorClause, isNotNull(missionTasks.notes)))
.orderBy(desc(missionTasks.createdAt), desc(missionTasks.id))
.limit(rowLimit);
return sortRows(rows.filter((row) => row.content !== '') as RowObject[]);
}
private async listMemory(
filter: FederationScopeQueryFilter,
rowLimit: number,
cursor: KeysetCursor | undefined,
): Promise<RowObject[]> {
if (!filter.includePersonal) {
return [];
}
const insightCursorClause = cursor
? 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([
this.db
.select({
id: insights.id,
kind: insights.source,
content: insights.content,
category: insights.category,
relevanceScore: insights.relevanceScore,
metadata: insights.metadata,
createdAt: insights.createdAt,
updatedAt: insights.updatedAt,
})
.from(insights)
.where(and(eq(insights.userId, filter.subjectUserId), insightCursorClause))
.orderBy(desc(insights.createdAt), desc(insights.id))
.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[])]);
}
}

View File

@@ -0,0 +1,143 @@
/**
* Federation list verb (FED-M3-05).
*
* POST /api/federation/v1/list/:resource
*
* Pipeline: FederationAuthGuard attaches the active grant context, then
* FederationScopeService enforces grant scope + native RBAC intersection, then
* the read-only query layer returns capped rows tagged with `_source`. Read
* audit-log writes are deferred to M4; this controller does not persist request
* or response bodies.
*/
import {
Body,
Controller,
HttpException,
Inject,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import {
FederationInvalidRequestError,
FederationScopeViolationError,
SOURCE_LOCAL,
tagWithSource,
type FederationListResponse,
type SourceTag,
} from '@mosaicstack/types';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import '../federation-context.js';
import { FederationScopeService } from '../scope.service.js';
import { FederationListQueryService } from './list-query.service.js';
interface FederationListRequestBody {
readonly limit?: unknown;
readonly cursor?: unknown;
}
type FederatedRow = Record<string, unknown> & SourceTag;
function parseLimit(body: FederationListRequestBody | undefined): number | undefined {
if (body?.limit === undefined) {
return undefined;
}
const parsed =
typeof body.limit === 'number'
? body.limit
: typeof body.limit === 'string' && body.limit.trim().length > 0
? Number(body.limit)
: Number.NaN;
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new HttpException(
new FederationInvalidRequestError(
'Federation list limit must be a positive integer',
).toEnvelope(),
400,
);
}
return parsed;
}
function parseCursor(body: FederationListRequestBody | undefined): string | undefined {
if (body?.cursor === undefined) {
return undefined;
}
if (typeof body.cursor === 'string') {
return body.cursor;
}
throw new HttpException(
new FederationInvalidRequestError('Federation list cursor must be a string').toEnvelope(),
400,
);
}
@Controller('api/federation/v1/list')
@UseGuards(FederationAuthGuard)
export class ListController {
constructor(
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
@Inject(FederationListQueryService) private readonly query: FederationListQueryService,
) {}
@Post(':resource')
async list(
@Param('resource') resource: string,
@Req() request: FastifyRequest,
@Body() body?: FederationListRequestBody,
): Promise<FederationListResponse<FederatedRow>> {
if (!request.federationContext) {
throw new Error('Federation context missing after auth guard');
}
const requestedLimit = parseLimit(body);
const cursor = parseCursor(body);
const scopeResult = await this.scope.evaluateAccess({
context: request.federationContext,
resource,
requestedLimit,
nativeRbac: this.query,
});
if (!scopeResult.allowed) {
const ErrorClass =
scopeResult.deny.statusCode === 400
? FederationInvalidRequestError
: FederationScopeViolationError;
throw new HttpException(
new ErrorClass(scopeResult.deny.message, scopeResult.deny).toEnvelope(),
scopeResult.deny.statusCode,
);
}
let result: Awaited<ReturnType<FederationListQueryService['list']>>;
try {
result = await this.query.list({ filter: scopeResult.filter, cursor });
} catch (error: unknown) {
if (error instanceof Error && error.message === 'Invalid federation list cursor') {
throw new HttpException(
new FederationInvalidRequestError('Federation list cursor is invalid').toEnvelope(),
400,
);
}
throw error;
}
const response: FederationListResponse<FederatedRow> = {
items: tagWithSource(result.items, SOURCE_LOCAL),
};
if (result.nextCursor !== undefined) {
response.nextCursor = result.nextCursor;
}
if (result.truncated) {
response._truncated = true;
}
return response;
}
}

View File

@@ -0,0 +1,60 @@
# Scratchpad — FED-M3-04 Scope Service
## Objective
Implement `apps/gateway/src/federation/server/scope.service.ts` for the M3 inbound federation scope-enforcement pipeline.
## Scope / Constraints
- Task: FED-M3-04, issue #462.
- Branch: `feat/federation-m3-scope-service` from `origin/main` @ 0.0.48.
- Pure service: no direct DB access; native RBAC/data access is injected per evaluation call.
- Reuse `parseFederationScope` from M2-03.
- Workers do not edit `docs/federation/TASKS.md` per repo AGENTS.md.
## Acceptance Criteria
1. Resource allowlist and `excluded_resources` enforced.
2. Native RBAC evaluated as `subjectUserId` through an injected evaluator.
3. Scope filter intersection supports `include_teams` and `include_personal` without widening native RBAC.
4. `max_rows_per_query` caps requested limits.
5. Service returns `{ allowed: true, filter }` or a structured deny reason usable by M4 audit.
6. Unit tests cover every deny path.
## Plan
1. Inspect existing federation scope/schema/auth guard contracts.
2. Add pure `FederationScopeService` plus typed result/filter/deny interfaces.
3. Add focused unit tests for happy paths, filter intersection, row cap, and deny paths.
4. Export/register service for future verb controllers.
5. Run situational tests, baseline gates, code review, then PR.
## Budget
- Provided model tier: sonnet.
- Estimate from task row: 10K tokens.
- Working cap assumption: keep implementation focused to FED-M3-04 surfaces only.
## Progress
- Intake complete; dirty base worktree avoided by creating isolated worktree at `/home/jarvis/src/mosaic-mono-v1-fed-m3-04`.
- Project PRD and federation task spec reviewed.
- Added `FederationScopeService` with structured allow/deny result types and injected native RBAC evaluator contract.
- Added unit coverage for happy path, row cap, filter intersection, and every deny path.
- Exported/registered the service for upcoming M3 verb controllers.
## Verification Evidence
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/__tests__/scope.service.spec.ts` — pass (10 tests before review update; 11 tests after adding include_personal no-leak coverage).
- `pnpm build` — pass (23 successful tasks).
- `pnpm typecheck` — pass (41 successful tasks; re-run after review update).
- `pnpm lint` — pass (23 successful tasks; re-run after review update).
- `pnpm format:check` — pass (re-run after review update).
- `pnpm test` — pass after starting local `postgres`/`valkey` and running `pnpm --filter @mosaicstack/db db:push` for the DB-backed cross-user isolation suite (41 successful tasks; gateway 477 passed / 11 skipped).
- Code review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings.
- Security review: `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — risk none, 0 findings.
## Risks / Blockers
- Issue #462 is already closed in provider output; likely milestone tracking mismatch. Will still reference #462 in PR body unless orchestrator redirects.
- Local full-test setup required `docker compose up -d postgres valkey` + `db:push`; containers were stopped with `docker compose down` after verification.

View File

@@ -0,0 +1,51 @@
# FED-M3-05 — Federation List Verb Scratchpad
## Objective
Implement `POST /api/federation/v1/list/:resource`.
## Scope
- Wire `FederationAuthGuard``FederationScopeService` → read-only list query layer.
- Apply `max_rows_per_query` row cap and return pagination metadata when truncated.
- Tag returned rows with `_source: "local"`.
- Keep audit writes deferred to M4.
- No request/response body persistence.
## Base / branch
- Branch: `feat/federation-m3-verb-list`
- Base: `feat/federation-m3-scope-service` (PR #672), per orchestrator, because M3-04 is not merged yet.
- Rebase target after #672 merges: `main`.
## Implementation notes
- Added `ListController` under `apps/gateway/src/federation/server/verbs/`.
- Added `FederationListQueryService` as the read-only query layer and native RBAC evaluator.
- Query resources supported in M3 list path:
- `tasks`: project/mission scoped tasks visible through personal/team project access.
- `notes`: non-empty `mission_tasks.notes` rows visible through personal/team mission access.
- `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.
- Cursor pagination uses an opaque base64url keyset cursor over `(createdAt, id)`; DB reads fetch at most `limit + 1` rows per resource query.
## Tests
- `pnpm --filter @mosaicstack/gateway test -- list.controller.spec.ts list-query.service.spec.ts` — PASS (9 tests).
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
- `pnpm format:check` — PASS.
- `pnpm typecheck` — PASS (41/41 turbo tasks).
- `pnpm lint` — PASS (23/23 turbo tasks).
- `pnpm --filter @mosaicstack/gateway test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup cannot connect to local PostgreSQL on `localhost:5433`. New list tests pass; failure is outside FED-M3-05.
## Review evidence
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS after remediation; approve, no findings.
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS after cursor 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.
## Risks / follow-up
- This branch intentionally includes M3-04 diff until PR #672 lands; final PR must be rebased onto main after #672 merges.
- Current branch base predates the M3-07 capabilities module registration; expect a small `FederationModule` rebase conflict once #672 and #674 are both on main.

View File

@@ -16,10 +16,6 @@
# --framework Install/upgrade framework only (skip npm CLI)
# --cli Install/upgrade npm CLI only (skip framework)
# --ref <branch> Git ref for framework archive (default: main)
# --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
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line
@@ -31,7 +27,6 @@
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
# MOSAIC_NO_COLOR — disable colour (set to 1)
# MOSAIC_REF — git ref for framework (default: main)
# MOSAIC_DEV — equivalent to --dev (set to 1)
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
# ──────────────────────────────────────────────────────────────────────────────
#
@@ -48,7 +43,6 @@ FLAG_CLI=true
FLAG_NO_AUTO_LAUNCH=false
FLAG_YES=false
FLAG_UNINSTALL=false
FLAG_DEV=false
GIT_REF="${MOSAIC_REF:-main}"
# MOSAIC_ASSUME_YES env var acts the same as --yes
@@ -56,18 +50,12 @@ if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
FLAG_YES=true
fi
# MOSAIC_DEV env var acts the same as --dev
if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
FLAG_DEV=true
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--check) FLAG_CHECK=true; shift ;;
--framework) FLAG_CLI=false; shift ;;
--cli) FLAG_FRAMEWORK=false; shift ;;
--ref) GIT_REF="${2:-main}"; shift 2 ;;
--dev) FLAG_DEV=true; shift ;;
--yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;;
@@ -84,17 +72,6 @@ CLI_PKG="${SCOPE}/mosaic"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
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=""
# ─── uninstall path ───────────────────────────────────────────────────────────
# Shell-level uninstall for when the CLI is broken or not available.
# Handles: framework directory, npm CLI package, npmrc scope line.
@@ -262,99 +239,6 @@ framework_version() {
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="$(ls -1t "$out_dir"/mosaicstack-mosaic-*.tgz 2>/dev/null | head -1)"
gw_tgz="$(ls -1t "$out_dir"/mosaicstack-gateway-*.tgz 2>/dev/null | head -1)"
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)"
}
# ─── preflight ────────────────────────────────────────────────────────────────
require_cmd node
@@ -398,8 +282,25 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
warn "Framework not installed."
fi
else
# Download repo archive and extract framework (shared with the dev build)
ensure_monorepo
# Download repo archive and extract framework
require_cmd tar
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"
if [[ ! -d "$FRAMEWORK_SRC" ]]; then
@@ -455,11 +356,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
fi
CURRENT="$(installed_cli_version)"
if [[ "$FLAG_DEV" == "true" ]]; then
LATEST=""
else
LATEST="$(latest_cli_version)"
fi
LATEST="$(latest_cli_version)"
if [[ -n "$CURRENT" ]]; then
dim " Installed: ${CLI_PKG}@${CURRENT}"
@@ -467,9 +364,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
dim " Installed: (none)"
fi
if [[ "$FLAG_DEV" == "true" ]]; then
dim " Source: ${REPO_BASE} (ref: ${GIT_REF}, build-from-source)"
elif [[ -n "$LATEST" ]]; then
if [[ -n "$LATEST" ]]; then
dim " Latest: ${CLI_PKG}@${LATEST}"
else
dim " Latest: (registry unreachable)"
@@ -477,9 +372,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ "$FLAG_DEV" == "true" ]]; then
info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
elif [[ -z "$LATEST" ]]; then
if [[ -z "$LATEST" ]]; then
warn "Could not reach registry."
elif [[ -z "$CURRENT" ]]; then
warn "Not installed."
@@ -490,16 +383,6 @@ if [[ "$FLAG_CLI" == "true" ]]; then
else
ok "Up to date (or ahead of registry)."
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
else
if [[ -z "$LATEST" ]]; then
warn "Could not reach registry at $REGISTRY — skipping npm CLI."