feat(api): complete phase 2.4 control plane
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildApp } from '../../app.js';
|
||||
import {
|
||||
InstanceSessionStore,
|
||||
UpstreamSessionClient,
|
||||
} from '../../application/connections/upstream-session-client.js';
|
||||
import { InstanceLoginService } from '../../application/connections/instance-login-service.js';
|
||||
import { ConnectionProbe } from '../../application/connections/connection-probe.js';
|
||||
import {
|
||||
InstanceService,
|
||||
InstanceServiceError,
|
||||
type InstanceServiceErrorCode,
|
||||
} from '../../application/instances/instance-service.js';
|
||||
import {
|
||||
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
|
||||
DeleteInstanceOperation,
|
||||
} from '../../application/operations/delete-instance-operation.js';
|
||||
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
import { registerInstanceRoutes } from './instance-routes.js';
|
||||
|
||||
class FakeStore implements SecretStore {
|
||||
async set(key: { instanceId: string; purpose: string; slot?: string }) {
|
||||
return `keychain://multi-simadmin/${Buffer.from(
|
||||
JSON.stringify([key.instanceId, key.purpose, key.slot]),
|
||||
'utf8',
|
||||
).toString('base64url')}`;
|
||||
}
|
||||
async get() {
|
||||
return undefined;
|
||||
}
|
||||
async delete() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const dbs: Database.Database[] = [];
|
||||
afterEach(() => {
|
||||
for (const db of dbs.splice(0)) db.close();
|
||||
});
|
||||
const fixture = () => {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys=ON');
|
||||
migrateDatabase(db);
|
||||
dbs.push(db);
|
||||
let id = 0;
|
||||
const instances = new InstanceService({
|
||||
db,
|
||||
store: new FakeStore(),
|
||||
idFactory: () => `id-${++id}`,
|
||||
now: () => new Date('2026-07-16T12:00:00.000Z'),
|
||||
});
|
||||
const connections = new ConnectionProbe({
|
||||
db,
|
||||
instances,
|
||||
transport: { get: async () => ({ status: 401, headers: {}, body: '' }) },
|
||||
now: () => new Date('2026-07-16T12:00:00.000Z'),
|
||||
});
|
||||
const sessionClient = new UpstreamSessionClient({
|
||||
sessions: new InstanceSessionStore(),
|
||||
request: async () => ({
|
||||
status: 200,
|
||||
headers: { 'set-cookie': 'simadmin_session=opaque' },
|
||||
body: '',
|
||||
}),
|
||||
});
|
||||
const login = new InstanceLoginService({
|
||||
db,
|
||||
client: sessionClient,
|
||||
resolver: { resolve: async () => '[REDACTED]' },
|
||||
now: () => new Date('2026-07-16T12:00:00.000Z'),
|
||||
});
|
||||
const deletion = new DeleteInstanceOperation({
|
||||
db,
|
||||
instances,
|
||||
now: () => new Date('2026-07-16T12:00:00.000Z'),
|
||||
idFactory: () => `operation-${++id}`,
|
||||
tokenFactory: () => Buffer.alloc(32, 11).toString('base64url'),
|
||||
});
|
||||
const app = buildApp({
|
||||
registerRoutes: (scope) =>
|
||||
registerInstanceRoutes(scope, { instances, connections, login, deletion }),
|
||||
});
|
||||
return { app, instances };
|
||||
};
|
||||
|
||||
describe('instance HTTP routes', () => {
|
||||
it('creates, lists, gets, updates and deletes redacted instance resources', async () => {
|
||||
const { app } = fixture();
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
payload: {
|
||||
name: 'Alpha',
|
||||
origin: 'http://192.168.1.10:8080',
|
||||
tags: ['lab'],
|
||||
password: { action: 'set', password: 'never-return-this' },
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(201);
|
||||
expect(create.headers.etag).toBe('"rev-1"');
|
||||
expect(JSON.stringify(create.json())).not.toContain('never-return-this');
|
||||
expect(create.json()).toMatchObject({ id: 'id-1', revision: 1, credentialConfigured: true });
|
||||
|
||||
const list = await app.inject({ method: 'GET', url: '/api/v1/instances?page=1&pageSize=20' });
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(list.json()).toMatchObject({ items: [expect.objectContaining({ id: 'id-1' })] });
|
||||
const get = await app.inject({ method: 'GET', url: '/api/v1/instances/id-1' });
|
||||
expect(get.statusCode).toBe(200);
|
||||
expect(get.headers.etag).toBe('"rev-1"');
|
||||
|
||||
const session = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances/id-1/test-connection',
|
||||
});
|
||||
expect(session.statusCode).toBe(200);
|
||||
expect(session.json()).toEqual({
|
||||
instanceId: 'id-1',
|
||||
authenticated: false,
|
||||
checkedAt: '2026-07-16T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances/id-1/login',
|
||||
payload: { password: 'one-shot-secret' },
|
||||
});
|
||||
expect(login.statusCode).toBe(200);
|
||||
expect(login.json()).toEqual({
|
||||
instanceId: 'id-1',
|
||||
authenticated: true,
|
||||
checkedAt: '2026-07-16T12:00:00.000Z',
|
||||
});
|
||||
expect(login.body).not.toContain('one-shot-secret');
|
||||
const logout = await app.inject({ method: 'POST', url: '/api/v1/instances/id-1/logout' });
|
||||
expect(logout.statusCode).toBe(200);
|
||||
expect(logout.json()).toMatchObject({ instanceId: 'id-1', authenticated: false });
|
||||
|
||||
const stale = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/instances/id-1',
|
||||
headers: { 'if-match': '"rev-2"' },
|
||||
payload: { name: 'Beta' },
|
||||
});
|
||||
expect(stale.statusCode).toBe(412);
|
||||
expect(stale.json()).toMatchObject({ code: 'REVISION_CONFLICT' });
|
||||
|
||||
const update = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/instances/id-1',
|
||||
headers: { 'if-match': '"rev-1"' },
|
||||
payload: { name: 'Beta', password: { action: 'preserve' } },
|
||||
});
|
||||
expect(update.statusCode).toBe(200);
|
||||
expect(update.headers.etag).toBe('"rev-2"');
|
||||
expect(update.json()).toMatchObject({ name: 'Beta', revision: 2 });
|
||||
|
||||
const directRemove = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/instances/id-1',
|
||||
headers: { 'if-match': '"rev-2"' },
|
||||
});
|
||||
expect(directRemove.statusCode).toBe(400);
|
||||
expect(directRemove.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
const preparation = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/operations/prepare',
|
||||
payload: {
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: 'id-1', revision: 2 }],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
},
|
||||
});
|
||||
expect(preparation.statusCode).toBe(200);
|
||||
const prepared = preparation.json() as { id: string; confirmationToken: string };
|
||||
const remove = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/instances/id-1',
|
||||
headers: {
|
||||
'if-match': '"rev-2"',
|
||||
'x-preparation-id': prepared.id,
|
||||
'x-confirmation-token': prepared.confirmationToken,
|
||||
},
|
||||
});
|
||||
expect(remove.statusCode).toBe(202);
|
||||
expect(remove.json()).toMatchObject({ operationId: 'deleteInstance', status: 'succeeded' });
|
||||
const replay = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/instances/id-1',
|
||||
headers: {
|
||||
'if-match': '"rev-2"',
|
||||
'x-preparation-id': prepared.id,
|
||||
'x-confirmation-token': prepared.confirmationToken,
|
||||
},
|
||||
});
|
||||
expect(replay.statusCode).toBe(400);
|
||||
expect(replay.json()).toMatchObject({ code: 'CONFIRMATION_INVALID' });
|
||||
expect((await app.inject({ method: 'GET', url: '/api/v1/instances/id-1' })).statusCode).toBe(
|
||||
404,
|
||||
);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects invalid input and maps domain errors to stable RFC problem details', async () => {
|
||||
const { app } = fixture();
|
||||
const invalid = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
payload: { name: '' },
|
||||
});
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
expect(invalid.headers['content-type']).toContain('application/problem+json');
|
||||
expect(invalid.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
const missing = await app.inject({ method: 'GET', url: '/api/v1/instances/nope' });
|
||||
expect(missing.statusCode).toBe(404);
|
||||
expect(missing.json()).toMatchObject({ code: 'NOT_FOUND' });
|
||||
const nullCreate = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: 'null',
|
||||
});
|
||||
expect(nullCreate.statusCode).toBe(400);
|
||||
expect(nullCreate.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
const arrayPatch = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/instances/nope',
|
||||
headers: { 'if-match': '"rev-1"' },
|
||||
payload: [],
|
||||
});
|
||||
expect(arrayPatch.statusCode).toBe(400);
|
||||
expect(arrayPatch.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
const strictCreateCases = [
|
||||
{ name: 'Alpha', origin: 'http://192.168.1.10', unexpected: true },
|
||||
{ name: 'Alpha', origin: 'http://192.168.1.10', tags: 'lab' },
|
||||
{ name: 'Alpha', origin: 'http://192.168.1.10', password: { action: 'set' } },
|
||||
];
|
||||
for (const payload of strictCreateCases) {
|
||||
const response = await app.inject({ method: 'POST', url: '/api/v1/instances', payload });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
}
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
payload: { name: 'Strict', origin: 'http://192.168.1.11' },
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
const strictId = (created.json() as { id: string }).id;
|
||||
for (const payload of [{}, { name: 123 }, { unexpected: true }]) {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/instances/${strictId}`,
|
||||
headers: { 'if-match': '"rev-1"' },
|
||||
payload,
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
}
|
||||
const unchanged = await app.inject({ method: 'GET', url: `/api/v1/instances/${strictId}` });
|
||||
expect(unchanged.json()).toMatchObject({ name: 'Strict', revision: 1 });
|
||||
|
||||
for (const payload of [
|
||||
{ password: { not: 'a string' } },
|
||||
{ password: '' },
|
||||
{ unexpected: true },
|
||||
]) {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/instances/${strictId}/login`,
|
||||
payload,
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
}
|
||||
const loginMissing = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances/nope/login',
|
||||
payload: {},
|
||||
});
|
||||
expect(loginMissing.statusCode).toBe(404);
|
||||
expect(loginMissing.json()).toMatchObject({ code: 'NOT_FOUND' });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('requires an exact strong revision ETag in If-Match', async () => {
|
||||
const { app } = fixture();
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
payload: { name: 'Strict ETag', origin: 'http://192.168.1.12' },
|
||||
});
|
||||
const id = (created.json() as { id: string }).id;
|
||||
for (const value of ['1', 'rev-1', 'W/"rev-1"', '"1"', '"rev-01"', '"rev-1", "rev-2"']) {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/instances/${id}`,
|
||||
headers: { 'if-match': value },
|
||||
payload: { name: 'Rejected' },
|
||||
});
|
||||
expect(response.statusCode, value).toBe(400);
|
||||
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('parses every frozen list filter and defaults pageSize to 25', async () => {
|
||||
const { app, instances } = fixture();
|
||||
const list = vi.spyOn(instances, 'list');
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/instances?page=2&pageSize=30&sort=status&direction=desc&search=alpha&capabilityStatus=degraded&freshness=stale&tag=lab&credentialConfigured=false',
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(list).toHaveBeenCalledWith({
|
||||
page: 2,
|
||||
pageSize: 30,
|
||||
sort: 'status',
|
||||
direction: 'desc',
|
||||
search: 'alpha',
|
||||
capabilityStatus: 'degraded',
|
||||
freshness: 'stale',
|
||||
tag: 'lab',
|
||||
credentialConfigured: false,
|
||||
});
|
||||
const defaults = await app.inject({ method: 'GET', url: '/api/v1/instances' });
|
||||
expect(defaults.json()).toMatchObject({ page: { page: 1, pageSize: 25, total: 0 } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects list query values outside the frozen OpenAPI constraints', async () => {
|
||||
const { app } = fixture();
|
||||
const invalidQueries = [
|
||||
'page=0',
|
||||
'page=1.5',
|
||||
'pageSize=0',
|
||||
'pageSize=101',
|
||||
'pageSize=abc',
|
||||
'sort=id',
|
||||
'direction=sideways',
|
||||
`search=${'x'.repeat(201)}`,
|
||||
'capabilityStatus=online',
|
||||
'freshness=old',
|
||||
'credentialConfigured=1',
|
||||
'credentialConfigured=TRUE',
|
||||
];
|
||||
for (const query of invalidQueries) {
|
||||
const response = await app.inject({ method: 'GET', url: `/api/v1/instances?${query}` });
|
||||
expect(response.statusCode, query).toBe(400);
|
||||
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('exhaustively maps and redacts instance service errors', async () => {
|
||||
const expected: Record<InstanceServiceErrorCode, number> = {
|
||||
VALIDATION_FAILED: 400,
|
||||
NOT_FOUND: 404,
|
||||
REVISION_CONFLICT: 412,
|
||||
HAS_JOB_HISTORY: 409,
|
||||
DUPLICATE_ID: 409,
|
||||
DUPLICATE_ORIGIN: 409,
|
||||
SECRET_STORE_FAILED: 500,
|
||||
DATABASE_FAILED: 500,
|
||||
SECRET_CLEANUP_FAILED: 500,
|
||||
COMPENSATION_FAILED: 500,
|
||||
COMPENSATION_PERSISTENCE_FAILED: 500,
|
||||
};
|
||||
for (const [code, status] of Object.entries(expected) as [InstanceServiceErrorCode, number][]) {
|
||||
const { app, instances } = fixture();
|
||||
vi.spyOn(instances, 'list').mockRejectedValue(
|
||||
new InstanceServiceError(code, 'sensitive internal failure detail'),
|
||||
);
|
||||
const response = await app.inject({ method: 'GET', url: '/api/v1/instances' });
|
||||
expect(response.statusCode, code).toBe(status);
|
||||
expect(response.headers['content-type']).toContain('application/problem+json');
|
||||
expect(response.json()).toMatchObject({ code, status });
|
||||
expect(response.body).not.toContain('sensitive internal failure detail');
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import type {
|
||||
InstanceInput,
|
||||
InstancePageQuery,
|
||||
InstancePatch,
|
||||
PasswordUpdate,
|
||||
Instance,
|
||||
} from '@multi-simadmin/contracts';
|
||||
import {
|
||||
InstanceLoginService,
|
||||
InstanceLoginServiceError,
|
||||
} from '../../application/connections/instance-login-service.js';
|
||||
import { ConnectionProbe } from '../../application/connections/connection-probe.js';
|
||||
import {
|
||||
InstanceService,
|
||||
InstanceServiceError,
|
||||
} from '../../application/instances/instance-service.js';
|
||||
import {
|
||||
DeleteInstanceOperation,
|
||||
DeleteInstanceOperationError,
|
||||
} from '../../application/operations/delete-instance-operation.js';
|
||||
|
||||
export interface InstanceRoutesOptions {
|
||||
readonly instances: InstanceService;
|
||||
readonly connections?: ConnectionProbe;
|
||||
readonly login?: InstanceLoginService;
|
||||
readonly deletion?: DeleteInstanceOperation;
|
||||
}
|
||||
const problem = (
|
||||
request: FastifyRequest,
|
||||
status: number,
|
||||
code: string,
|
||||
detail: string,
|
||||
): Record<string, unknown> => ({
|
||||
type: 'about:blank',
|
||||
title:
|
||||
status === 500
|
||||
? 'Internal Server Error'
|
||||
: status === 404
|
||||
? 'Not Found'
|
||||
: status === 412
|
||||
? 'Precondition Failed'
|
||||
: status === 409
|
||||
? 'Conflict'
|
||||
: 'Bad Request',
|
||||
status,
|
||||
code,
|
||||
detail,
|
||||
requestId: request.id,
|
||||
});
|
||||
const domainProblem = (request: FastifyRequest, error: InstanceServiceError) => {
|
||||
const status = instanceErrorStatus[error.code];
|
||||
return problem(
|
||||
request,
|
||||
status,
|
||||
error.code,
|
||||
'The requested instance operation could not be completed.',
|
||||
);
|
||||
};
|
||||
const instanceErrorStatus = {
|
||||
VALIDATION_FAILED: 400,
|
||||
NOT_FOUND: 404,
|
||||
REVISION_CONFLICT: 412,
|
||||
HAS_JOB_HISTORY: 409,
|
||||
DUPLICATE_ID: 409,
|
||||
DUPLICATE_ORIGIN: 409,
|
||||
SECRET_STORE_FAILED: 500,
|
||||
DATABASE_FAILED: 500,
|
||||
SECRET_CLEANUP_FAILED: 500,
|
||||
COMPENSATION_FAILED: 500,
|
||||
COMPENSATION_PERSISTENCE_FAILED: 500,
|
||||
} as const satisfies Record<
|
||||
import('../../application/instances/instance-service.js').InstanceServiceErrorCode,
|
||||
number
|
||||
>;
|
||||
const etag = (instance: Instance): string => `"rev-${instance.revision}"`;
|
||||
const revision = (request: FastifyRequest): number => {
|
||||
const value = request.headers['if-match'];
|
||||
const match = typeof value === 'string' ? /^"rev-([1-9]\d*)"$/.exec(value) : null;
|
||||
if (!match) throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid revision');
|
||||
const parsed = Number(match[1]);
|
||||
if (!Number.isSafeInteger(parsed))
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid revision');
|
||||
return parsed;
|
||||
};
|
||||
const strings = (value: unknown): readonly string[] | undefined =>
|
||||
Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined;
|
||||
const record = (body: unknown): Record<string, unknown> => {
|
||||
if (body === null || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Request body must be an object');
|
||||
return body as Record<string, unknown>;
|
||||
};
|
||||
const bodyInput = (body: unknown): InstanceInput => {
|
||||
const value = record(body);
|
||||
const tags = strings(value.tags);
|
||||
const patch: {
|
||||
name: string;
|
||||
origin: string;
|
||||
tags?: readonly string[];
|
||||
password?: PasswordUpdate;
|
||||
} = {
|
||||
name: typeof value.name === 'string' ? value.name : '',
|
||||
origin: typeof value.origin === 'string' ? value.origin : '',
|
||||
};
|
||||
if (tags) patch.tags = tags;
|
||||
if (value.password && typeof value.password === 'object')
|
||||
patch.password = value.password as PasswordUpdate;
|
||||
return patch;
|
||||
};
|
||||
const bodyPatch = (body: unknown): InstancePatch => {
|
||||
const value = record(body);
|
||||
const tags = strings(value.tags);
|
||||
const patch: {
|
||||
name?: string;
|
||||
origin?: string;
|
||||
tags?: readonly string[];
|
||||
password?: PasswordUpdate;
|
||||
} = {};
|
||||
if (typeof value.name === 'string') patch.name = value.name;
|
||||
if (typeof value.origin === 'string') patch.origin = value.origin;
|
||||
if (tags) patch.tags = tags;
|
||||
if (value.password && typeof value.password === 'object')
|
||||
patch.password = value.password as PasswordUpdate;
|
||||
return patch;
|
||||
};
|
||||
const page = (query: unknown): InstancePageQuery => {
|
||||
const value = query as Record<string, unknown>;
|
||||
const optionalString = (key: string): string | undefined => {
|
||||
const item = value[key];
|
||||
if (item === undefined) return undefined;
|
||||
if (typeof item !== 'string')
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`);
|
||||
return item;
|
||||
};
|
||||
const integer = (key: 'page' | 'pageSize', maximum?: number) => {
|
||||
const item = optionalString(key);
|
||||
if (item === undefined) return undefined;
|
||||
if (!/^[1-9]\d*$/.test(item))
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`);
|
||||
const parsed = Number(item);
|
||||
if (!Number.isSafeInteger(parsed) || (maximum !== undefined && parsed > maximum))
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`);
|
||||
return parsed;
|
||||
};
|
||||
const queryValue: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: 'name' | 'status' | 'freshness' | 'updatedAt';
|
||||
direction?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
tag?: string;
|
||||
capabilityStatus?: 'supported' | 'unsupported' | 'auth-required' | 'degraded' | 'unknown';
|
||||
freshness?: 'fresh' | 'stale' | 'expired' | 'unknown';
|
||||
credentialConfigured?: boolean;
|
||||
} = {};
|
||||
const parsedPage = integer('page');
|
||||
const parsedPageSize = integer('pageSize', 100);
|
||||
if (parsedPage !== undefined) queryValue.page = parsedPage;
|
||||
if (parsedPageSize !== undefined) queryValue.pageSize = parsedPageSize;
|
||||
const sort = optionalString('sort');
|
||||
if (sort !== undefined) {
|
||||
if (!['name', 'status', 'freshness', 'updatedAt'].includes(sort))
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid sort');
|
||||
queryValue.sort = sort as NonNullable<typeof queryValue.sort>;
|
||||
}
|
||||
const direction = optionalString('direction');
|
||||
if (direction !== undefined) {
|
||||
if (direction !== 'asc' && direction !== 'desc')
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid direction');
|
||||
queryValue.direction = direction;
|
||||
}
|
||||
const search = optionalString('search');
|
||||
if (search !== undefined) {
|
||||
if (search.length > 200) throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid search');
|
||||
queryValue.search = search;
|
||||
}
|
||||
const tag = optionalString('tag');
|
||||
if (tag !== undefined) queryValue.tag = tag;
|
||||
const capabilityStatus = optionalString('capabilityStatus');
|
||||
if (capabilityStatus !== undefined) {
|
||||
if (
|
||||
!['supported', 'unsupported', 'auth-required', 'degraded', 'unknown'].includes(
|
||||
capabilityStatus,
|
||||
)
|
||||
)
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid capabilityStatus');
|
||||
queryValue.capabilityStatus = capabilityStatus as NonNullable<
|
||||
typeof queryValue.capabilityStatus
|
||||
>;
|
||||
}
|
||||
const freshness = optionalString('freshness');
|
||||
if (freshness !== undefined) {
|
||||
if (!['fresh', 'stale', 'expired', 'unknown'].includes(freshness))
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid freshness');
|
||||
queryValue.freshness = freshness as NonNullable<typeof queryValue.freshness>;
|
||||
}
|
||||
const credentialConfigured = optionalString('credentialConfigured');
|
||||
if (credentialConfigured !== undefined) {
|
||||
if (credentialConfigured !== 'true' && credentialConfigured !== 'false')
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid credentialConfigured');
|
||||
queryValue.credentialConfigured = credentialConfigured === 'true';
|
||||
}
|
||||
return queryValue;
|
||||
};
|
||||
const passwordUpdateSchema = {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['action'],
|
||||
properties: { action: { const: 'preserve' } },
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['action', 'password'],
|
||||
properties: { action: { const: 'set' }, password: { type: 'string', minLength: 1 } },
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['action'],
|
||||
properties: { action: { const: 'clear' } },
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
const instanceProperties = {
|
||||
name: { type: 'string' },
|
||||
origin: { type: 'string' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
password: passwordUpdateSchema,
|
||||
} as const;
|
||||
const instanceInputSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['name', 'origin'],
|
||||
properties: instanceProperties,
|
||||
} as const;
|
||||
const instancePatchSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
minProperties: 1,
|
||||
properties: instanceProperties,
|
||||
} as const;
|
||||
const loginInputSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { password: { type: 'string', minLength: 1 } },
|
||||
} as const;
|
||||
|
||||
export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRoutesOptions): void {
|
||||
const wrap =
|
||||
<T>(
|
||||
handler: (
|
||||
request: FastifyRequest,
|
||||
reply: { header: (name: string, value: string) => unknown },
|
||||
) => Promise<T>,
|
||||
) =>
|
||||
async (
|
||||
request: FastifyRequest,
|
||||
reply: {
|
||||
code: (status: number) => typeof reply;
|
||||
header: (name: string, value: string) => typeof reply;
|
||||
type: (mime: string) => typeof reply;
|
||||
send: (body: unknown) => unknown;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
return await handler(request, reply);
|
||||
} catch (error) {
|
||||
if (error instanceof InstanceServiceError)
|
||||
return reply
|
||||
.code(instanceErrorStatus[error.code])
|
||||
.type('application/problem+json')
|
||||
.send(domainProblem(request, error));
|
||||
if (error instanceof DeleteInstanceOperationError) {
|
||||
const status =
|
||||
error.code === 'NOT_FOUND' ? 404 : error.code === 'REVISION_CONFLICT' ? 412 : 400;
|
||||
return reply
|
||||
.code(status)
|
||||
.type('application/problem+json')
|
||||
.send(
|
||||
problem(
|
||||
request,
|
||||
status,
|
||||
error.code,
|
||||
'The destructive operation could not be confirmed.',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (error instanceof InstanceLoginServiceError)
|
||||
return reply
|
||||
.code(error.code === 'NOT_FOUND' ? 404 : 409)
|
||||
.type('application/problem+json')
|
||||
.send(
|
||||
problem(
|
||||
request,
|
||||
error.code === 'NOT_FOUND' ? 404 : 409,
|
||||
error.code,
|
||||
'The requested session operation could not be completed.',
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
app.get(
|
||||
'/api/v1/instances',
|
||||
wrap(async (request) => options.instances.list(page(request.query))),
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/instances',
|
||||
{ schema: { body: instanceInputSchema } },
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const created = await options.instances.create(bodyInput(request.body));
|
||||
return reply.header('ETag', etag(created)).code(201).send(created);
|
||||
} catch (error) {
|
||||
if (error instanceof InstanceServiceError)
|
||||
return reply
|
||||
.code(instanceErrorStatus[error.code])
|
||||
.type('application/problem+json')
|
||||
.send(domainProblem(request, error));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/api/v1/instances/:instanceId',
|
||||
wrap(async (request, reply) => {
|
||||
const result = await options.instances.get(
|
||||
(request.params as { instanceId: string }).instanceId,
|
||||
);
|
||||
if (!result) throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
|
||||
reply.header('ETag', etag(result));
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
app.patch(
|
||||
'/api/v1/instances/:instanceId',
|
||||
{ schema: { body: instancePatchSchema } },
|
||||
wrap(async (request, reply) => {
|
||||
const result = await options.instances.update(
|
||||
(request.params as { instanceId: string }).instanceId,
|
||||
revision(request),
|
||||
bodyPatch(request.body),
|
||||
);
|
||||
reply.header('ETag', etag(result));
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
if (options.connections)
|
||||
app.post(
|
||||
'/api/v1/instances/:instanceId/test-connection',
|
||||
wrap(async (request) =>
|
||||
options.connections!.test((request.params as { instanceId: string }).instanceId),
|
||||
),
|
||||
);
|
||||
|
||||
if (options.login) {
|
||||
app.post(
|
||||
'/api/v1/instances/:instanceId/login',
|
||||
{ schema: { body: loginInputSchema } },
|
||||
wrap(async (request) => {
|
||||
const body = request.body as Record<string, unknown> | undefined;
|
||||
if (body?.password !== undefined && typeof body.password !== 'string')
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Password must be a string');
|
||||
return options.login!.login(
|
||||
(request.params as { instanceId: string }).instanceId,
|
||||
body?.password,
|
||||
);
|
||||
}),
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/instances/:instanceId/logout',
|
||||
wrap(async (request) =>
|
||||
options.login!.logout((request.params as { instanceId: string }).instanceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.deletion) {
|
||||
app.post(
|
||||
'/api/v1/operations/prepare',
|
||||
wrap(async (request) => options.deletion!.prepare(request.body as never, request.id)),
|
||||
);
|
||||
app.delete('/api/v1/instances/:instanceId', async (request, reply) => {
|
||||
const handled = await wrap(async (wrappedRequest) => {
|
||||
const preparationId = wrappedRequest.headers['x-preparation-id'];
|
||||
const confirmationToken = wrappedRequest.headers['x-confirmation-token'];
|
||||
if (
|
||||
typeof preparationId !== 'string' ||
|
||||
preparationId.length === 0 ||
|
||||
typeof confirmationToken !== 'string' ||
|
||||
confirmationToken.length < 20
|
||||
)
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Confirmation headers are required');
|
||||
return options.deletion!.executeDelete({
|
||||
instanceId: (wrappedRequest.params as { instanceId: string }).instanceId,
|
||||
revision: revision(wrappedRequest),
|
||||
preparationId,
|
||||
confirmationToken,
|
||||
actor: 'loopback-control-plane',
|
||||
requestId: wrappedRequest.id,
|
||||
});
|
||||
})(request, reply);
|
||||
if (reply.sent) return;
|
||||
return reply.code(202).send(handled);
|
||||
});
|
||||
} else {
|
||||
app.delete(
|
||||
'/api/v1/instances/:instanceId',
|
||||
wrap(async () => {
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Confirmed deletion is unavailable');
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user