feat(fleet): card-only UI with progressive load and restart ops
Drop the redundant advanced table so fleet stays resource-first cards. Keep multi-select batch service/system restart via prepare→execute, card restarts, overview system ops, and progressive fleet loading.
This commit is contained in:
@@ -213,3 +213,76 @@ describe('SecureOperationExecution generic R2 slice', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SecureOperationExecution R3 restart slice', () => {
|
||||
it('prepares and executes service restart as a zero-body R3 operation', async () => {
|
||||
const { db, execution, request } = fixture();
|
||||
const prepared = await execution.prepare({
|
||||
operationId: 'postServiceRestart',
|
||||
targets: [{ instanceId: 'i-1', revision: 3 }],
|
||||
parameters: {
|
||||
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
|
||||
fields: [],
|
||||
},
|
||||
});
|
||||
expect(prepared).toMatchObject({
|
||||
operationId: 'postServiceRestart',
|
||||
risk: 'R3',
|
||||
status: 'prepared',
|
||||
targetCount: 1,
|
||||
});
|
||||
const job = await execution.execute(
|
||||
{ preparationId: prepared.id, confirmationToken: prepared.confirmationToken },
|
||||
'actor',
|
||||
'r-restart',
|
||||
);
|
||||
expect(job).toMatchObject({ operationId: 'postServiceRestart', status: 'succeeded' });
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
origin: 'http://192.168.1.10',
|
||||
method: 'POST',
|
||||
path: '/api/service/restart',
|
||||
query: '',
|
||||
contentType: '',
|
||||
body: undefined,
|
||||
});
|
||||
expect(
|
||||
db.prepare('SELECT risk_level FROM jobs WHERE id=?').get(job.id),
|
||||
).toEqual({ risk_level: 'R3' });
|
||||
});
|
||||
|
||||
it('prepares and executes system reboot only with fixed delay_seconds=3', async () => {
|
||||
const { execution, request } = fixture();
|
||||
await expect(
|
||||
execution.prepare({
|
||||
operationId: 'postSystemReboot',
|
||||
targets: [{ instanceId: 'i-1', revision: 3 }],
|
||||
parameters: {
|
||||
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
|
||||
fields: [{ fieldId: 'delay_seconds', kind: 'number', value: 1 }],
|
||||
},
|
||||
} as never),
|
||||
).rejects.toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
const prepared = await execution.prepare({
|
||||
operationId: 'postSystemReboot',
|
||||
targets: [{ instanceId: 'i-1', revision: 3 }],
|
||||
parameters: {
|
||||
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
|
||||
fields: [{ fieldId: 'delay_seconds', kind: 'number', value: 3 }],
|
||||
},
|
||||
});
|
||||
expect(prepared.risk).toBe('R3');
|
||||
await execution.execute(
|
||||
{ preparationId: prepared.id, confirmationToken: prepared.confirmationToken },
|
||||
'actor',
|
||||
'r-reboot',
|
||||
);
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
origin: 'http://192.168.1.10',
|
||||
method: 'POST',
|
||||
path: '/api/system/reboot',
|
||||
query: '',
|
||||
contentType: 'application/json',
|
||||
body: '{"delay_seconds":3}',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,22 +18,47 @@ export interface SecureOperationDescriptor {
|
||||
export interface SecureOperationRegistry {
|
||||
requireExecutableOperation(operationId: string): SecureOperationDescriptor;
|
||||
}
|
||||
const EXECUTABLE_OPERATIONS: Readonly<Record<string, SecureOperationDescriptor>> = Object.freeze({
|
||||
postNetworkRegisterAuto: Object.freeze({
|
||||
operationId: 'postNetworkRegisterAuto',
|
||||
title: 'Register Network Automatically',
|
||||
riskLevel: 'R2',
|
||||
method: 'POST',
|
||||
pathTemplate: '/api/network/register-auto',
|
||||
requestContentType: 'none',
|
||||
parameterSchemaId: 'simadmin.58e2204.postNetworkRegisterAuto.parameters.v1',
|
||||
}),
|
||||
postServiceRestart: Object.freeze({
|
||||
operationId: 'postServiceRestart',
|
||||
title: 'Restart Service',
|
||||
riskLevel: 'R3',
|
||||
method: 'POST',
|
||||
pathTemplate: '/api/service/restart',
|
||||
requestContentType: 'none',
|
||||
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
|
||||
}),
|
||||
postSystemReboot: Object.freeze({
|
||||
operationId: 'postSystemReboot',
|
||||
title: 'Reboot System',
|
||||
riskLevel: 'R3',
|
||||
method: 'POST',
|
||||
pathTemplate: '/api/system/reboot',
|
||||
requestContentType: 'application/json',
|
||||
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
|
||||
}),
|
||||
});
|
||||
|
||||
export const secureOperationRegistry: SecureOperationRegistry = {
|
||||
requireExecutableOperation(operationId) {
|
||||
if (operationId !== 'postNetworkRegisterAuto') throw new Error('UNKNOWN_OPERATION');
|
||||
return {
|
||||
operationId: 'postNetworkRegisterAuto',
|
||||
title: 'Register Network Automatically',
|
||||
riskLevel: 'R2',
|
||||
method: 'POST',
|
||||
pathTemplate: '/api/network/register-auto',
|
||||
requestContentType: 'none',
|
||||
parameterSchemaId: 'simadmin.58e2204.postNetworkRegisterAuto.parameters.v1',
|
||||
};
|
||||
const descriptor = EXECUTABLE_OPERATIONS[operationId];
|
||||
if (!descriptor) throw new Error('UNKNOWN_OPERATION');
|
||||
return descriptor;
|
||||
},
|
||||
};
|
||||
|
||||
const ALLOWED_OPERATION = 'postNetworkRegisterAuto';
|
||||
const ZERO_BODY_OPERATIONS = new Set(['postNetworkRegisterAuto', 'postServiceRestart']);
|
||||
const SYSTEM_REBOOT_OPERATION = 'postSystemReboot';
|
||||
const SYSTEM_REBOOT_DELAY_SECONDS = 3;
|
||||
const ACTOR = 'loopback-control-plane';
|
||||
const TTL_MS = 5 * 60 * 1000;
|
||||
const EMPTY_DIGEST = createHash('sha256').update('').digest('hex');
|
||||
@@ -44,7 +69,8 @@ export interface SafeOperationTransportRequest {
|
||||
readonly path: string;
|
||||
readonly query: string;
|
||||
readonly contentType: string;
|
||||
readonly body: undefined;
|
||||
/** Serialized JSON body for audited JSON operations; undefined for zero-body POSTs. */
|
||||
readonly body: string | undefined;
|
||||
}
|
||||
export interface SafeOperationTransport {
|
||||
request(request: SafeOperationTransportRequest): Promise<{ readonly status: number }>;
|
||||
@@ -107,8 +133,8 @@ const equalDigest = (left: string, right: string): boolean => {
|
||||
};
|
||||
const validRevision = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||
const parameterDigest = (schema: string): string =>
|
||||
digest(JSON.stringify({ parameterSchemaId: schema, fields: [] }));
|
||||
const parameterDigest = (schema: string, fields: readonly unknown[] = []): string =>
|
||||
digest(JSON.stringify({ parameterSchemaId: schema, fields }));
|
||||
|
||||
export class SecureOperationExecution {
|
||||
private readonly clock: () => Date;
|
||||
@@ -131,11 +157,6 @@ export class SecureOperationExecution {
|
||||
typeof input.operationId !== 'string'
|
||||
)
|
||||
this.validation();
|
||||
if (input.operationId !== ALLOWED_OPERATION)
|
||||
throw new SecureOperationExecutionError(
|
||||
'OPERATION_NOT_ALLOWED',
|
||||
'Operation is not enabled for generic execution',
|
||||
);
|
||||
let descriptor: SecureOperationDescriptor;
|
||||
try {
|
||||
descriptor = this.options.registry.requireExecutableOperation(input.operationId);
|
||||
@@ -146,10 +167,9 @@ export class SecureOperationExecution {
|
||||
);
|
||||
}
|
||||
if (
|
||||
descriptor.riskLevel !== 'R2' ||
|
||||
descriptor.operationId !== input.operationId ||
|
||||
(descriptor.riskLevel !== 'R2' && descriptor.riskLevel !== 'R3') ||
|
||||
descriptor.method !== 'POST' ||
|
||||
descriptor.pathTemplate !== '/api/network/register-auto' ||
|
||||
descriptor.requestContentType !== 'none' ||
|
||||
!Array.isArray(input.targets) ||
|
||||
input.targets.length !== 1
|
||||
)
|
||||
@@ -172,10 +192,34 @@ export class SecureOperationExecution {
|
||||
Array.isArray(parameters) ||
|
||||
Object.keys(parameters).some((key) => !['parameterSchemaId', 'fields'].includes(key)) ||
|
||||
parameters.parameterSchemaId !== descriptor.parameterSchemaId ||
|
||||
!Array.isArray(parameters.fields) ||
|
||||
parameters.fields.length !== 0
|
||||
!Array.isArray(parameters.fields)
|
||||
)
|
||||
this.validation();
|
||||
let bodyDigest = EMPTY_DIGEST;
|
||||
let contentType = '';
|
||||
let serializedBody: string | undefined;
|
||||
if (ZERO_BODY_OPERATIONS.has(descriptor.operationId)) {
|
||||
if (descriptor.requestContentType !== 'none' || parameters.fields.length !== 0) this.validation();
|
||||
} else if (descriptor.operationId === SYSTEM_REBOOT_OPERATION) {
|
||||
if (descriptor.requestContentType !== 'application/json' || parameters.fields.length !== 1)
|
||||
this.validation();
|
||||
const field = parameters.fields[0] as { fieldId?: unknown; kind?: unknown; value?: unknown };
|
||||
if (
|
||||
!field ||
|
||||
typeof field !== 'object' ||
|
||||
Array.isArray(field) ||
|
||||
Object.keys(field).some((key) => !['fieldId', 'kind', 'value'].includes(key)) ||
|
||||
field.fieldId !== 'delay_seconds' ||
|
||||
field.kind !== 'number' ||
|
||||
field.value !== SYSTEM_REBOOT_DELAY_SECONDS
|
||||
)
|
||||
this.validation();
|
||||
serializedBody = JSON.stringify({ delay_seconds: SYSTEM_REBOOT_DELAY_SECONDS });
|
||||
bodyDigest = digest(serializedBody);
|
||||
contentType = 'application/json';
|
||||
} else {
|
||||
this.validation();
|
||||
}
|
||||
const instance = this.options.db
|
||||
.prepare('SELECT base_url,config_revision FROM instances WHERE id=? AND enabled=1')
|
||||
.get(target.instanceId) as { base_url: string; config_revision: number } | undefined;
|
||||
@@ -216,10 +260,10 @@ export class SecureOperationExecution {
|
||||
descriptor.method,
|
||||
descriptor.pathTemplate,
|
||||
'',
|
||||
EMPTY_DIGEST,
|
||||
'',
|
||||
bodyDigest,
|
||||
contentType,
|
||||
descriptor.parameterSchemaId,
|
||||
parameterDigest(descriptor.parameterSchemaId),
|
||||
parameterDigest(descriptor.parameterSchemaId, parameters.fields),
|
||||
this.nonce(),
|
||||
digest(token),
|
||||
ACTOR,
|
||||
@@ -232,7 +276,7 @@ export class SecureOperationExecution {
|
||||
id,
|
||||
status: 'prepared',
|
||||
operationId: descriptor.operationId,
|
||||
risk: 'R2',
|
||||
risk: descriptor.riskLevel as 'R2' | 'R3',
|
||||
expiresAt,
|
||||
confirmationToken: token,
|
||||
confirmationPrompt: `Execute ${descriptor.title} on ${target.instanceId}?`,
|
||||
@@ -278,16 +322,28 @@ export class SecureOperationExecution {
|
||||
const instance = this.options.db
|
||||
.prepare('SELECT base_url,config_revision FROM instances WHERE id=? AND enabled=1')
|
||||
.get(row.target_instance_id) as { base_url: string; config_revision: number } | undefined;
|
||||
const expectedBody =
|
||||
descriptor.operationId === SYSTEM_REBOOT_OPERATION
|
||||
? JSON.stringify({ delay_seconds: SYSTEM_REBOOT_DELAY_SECONDS })
|
||||
: undefined;
|
||||
const expectedBodyDigest = expectedBody ? digest(expectedBody) : EMPTY_DIGEST;
|
||||
const expectedContentType =
|
||||
descriptor.operationId === SYSTEM_REBOOT_OPERATION ? 'application/json' : '';
|
||||
const expectedFields =
|
||||
descriptor.operationId === SYSTEM_REBOOT_OPERATION
|
||||
? [{ fieldId: 'delay_seconds', kind: 'number', value: SYSTEM_REBOOT_DELAY_SECONDS }]
|
||||
: [];
|
||||
const bindingValid =
|
||||
descriptor.operationId === ALLOWED_OPERATION &&
|
||||
descriptor.riskLevel === 'R2' &&
|
||||
!!EXECUTABLE_OPERATIONS[descriptor.operationId] &&
|
||||
(descriptor.riskLevel === 'R2' || descriptor.riskLevel === 'R3') &&
|
||||
descriptor.riskLevel === row.risk_level &&
|
||||
descriptor.method === row.method &&
|
||||
descriptor.pathTemplate === row.path &&
|
||||
descriptor.parameterSchemaId === row.parameter_schema_id &&
|
||||
row.canonical_query === '' &&
|
||||
row.content_type === '' &&
|
||||
row.body_digest === EMPTY_DIGEST &&
|
||||
row.parameters_digest === parameterDigest(row.parameter_schema_id) &&
|
||||
row.content_type === expectedContentType &&
|
||||
row.body_digest === expectedBodyDigest &&
|
||||
row.parameters_digest === parameterDigest(row.parameter_schema_id, expectedFields) &&
|
||||
!!row.nonce &&
|
||||
!!instance &&
|
||||
instance.base_url === row.target_origin &&
|
||||
@@ -310,12 +366,13 @@ export class SecureOperationExecution {
|
||||
.prepare(
|
||||
`INSERT INTO jobs
|
||||
(id,parent_job_id,root_job_id,retry_of_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,started_at,updated_at)
|
||||
VALUES (?,NULL,?,NULL,?,'R2','running',?,?,?,?,?,?)`,
|
||||
VALUES (?,NULL,?,NULL,?,?,'running',?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
ids.job,
|
||||
ids.job,
|
||||
row.operation_id,
|
||||
row.risk_level,
|
||||
actor,
|
||||
requestId,
|
||||
row.parameters_digest,
|
||||
@@ -350,8 +407,11 @@ export class SecureOperationExecution {
|
||||
method: 'POST',
|
||||
path: bound.path,
|
||||
query: '',
|
||||
contentType: '',
|
||||
body: undefined,
|
||||
contentType: bound.content_type,
|
||||
body:
|
||||
bound.operation_id === SYSTEM_REBOOT_OPERATION
|
||||
? JSON.stringify({ delay_seconds: SYSTEM_REBOOT_DELAY_SECONDS })
|
||||
: undefined,
|
||||
});
|
||||
state = response.status >= 200 && response.status < 300 ? 'succeeded' : 'failed';
|
||||
code = state === 'succeeded' ? 'UPSTREAM_SUCCEEDED' : 'UPSTREAM_REJECTED';
|
||||
@@ -373,9 +433,9 @@ export class SecureOperationExecution {
|
||||
return this.options.db.transaction(() => {
|
||||
const jobs = this.options.db
|
||||
.prepare(
|
||||
"SELECT id FROM jobs WHERE operation_id=? AND risk_level='R2' AND status='running'",
|
||||
"SELECT id FROM jobs WHERE operation_id IN ('postNetworkRegisterAuto','postServiceRestart','postSystemReboot') AND risk_level IN ('R2','R3') AND status='running'",
|
||||
)
|
||||
.all(ALLOWED_OPERATION) as Array<{ id: string }>;
|
||||
.all() as Array<{ id: string }>;
|
||||
for (const row of jobs) this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
|
||||
return jobs.length;
|
||||
})();
|
||||
|
||||
@@ -23,6 +23,14 @@ it('registers the read-only operations catalog without touching upstream', async
|
||||
upstreamCalls += 1;
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
postServiceRestart: async () => {
|
||||
upstreamCalls += 1;
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
postSystemReboot: async () => {
|
||||
upstreamCalls += 1;
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await app.inject('/api/v1/operations?pageSize=1');
|
||||
|
||||
@@ -35,6 +35,8 @@ describe('buildControlPlaneApp', () => {
|
||||
get: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
request: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
||||
postServiceRestart: async () => ({ status: 200 }),
|
||||
postSystemReboot: async () => ({ status: 200 }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,6 +66,8 @@ describe('buildControlPlaneApp', () => {
|
||||
get: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
request: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
||||
postServiceRestart: async () => ({ status: 200 }),
|
||||
postSystemReboot: async () => ({ status: 200 }),
|
||||
},
|
||||
authenticateEventStream: (request) => request.headers.authorization === 'Bearer allowed',
|
||||
});
|
||||
@@ -150,6 +154,8 @@ describe('buildControlPlaneApp', () => {
|
||||
},
|
||||
request: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
||||
postServiceRestart: async () => ({ status: 200 }),
|
||||
postSystemReboot: async () => ({ status: 200 }),
|
||||
},
|
||||
});
|
||||
const created = await app.inject({
|
||||
|
||||
@@ -36,6 +36,8 @@ import { InstanceMessageService } from './application/messages/instance-message-
|
||||
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||
request: UpstreamSessionClientOptions['request'];
|
||||
postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>;
|
||||
postServiceRestart(origin: string): Promise<{ readonly status: number }>;
|
||||
postSystemReboot(origin: string, delaySeconds: number): Promise<{ readonly status: number }>;
|
||||
}
|
||||
export interface ControlPlaneOptions {
|
||||
readonly db: Database.Database;
|
||||
@@ -87,9 +89,22 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
||||
db: options.db,
|
||||
registry: secureOperationRegistry,
|
||||
transport: {
|
||||
request: async ({ origin }) => {
|
||||
const response = await options.upstream.postNetworkRegisterAuto(origin);
|
||||
return { status: response.status };
|
||||
request: async ({ origin, path, body, contentType }) => {
|
||||
if (path === '/api/network/register-auto') {
|
||||
const response = await options.upstream.postNetworkRegisterAuto(origin);
|
||||
return { status: response.status };
|
||||
}
|
||||
if (path === '/api/service/restart') {
|
||||
const response = await options.upstream.postServiceRestart(origin);
|
||||
return { status: response.status };
|
||||
}
|
||||
if (path === '/api/system/reboot') {
|
||||
if (contentType !== 'application/json' || body !== JSON.stringify({ delay_seconds: 3 }))
|
||||
throw new Error('UPSTREAM_REQUEST_INVALID');
|
||||
const response = await options.upstream.postSystemReboot(origin, 3);
|
||||
return { status: response.status };
|
||||
}
|
||||
throw new Error('UPSTREAM_REQUEST_INVALID');
|
||||
},
|
||||
},
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
|
||||
@@ -23,6 +23,8 @@ describe('createSafeControlPlaneUpstream', () => {
|
||||
body: '[REDACTED]',
|
||||
});
|
||||
await upstream.postNetworkRegisterAuto('http://192.168.1.20');
|
||||
await upstream.postServiceRestart('http://192.168.1.20');
|
||||
await upstream.postSystemReboot('http://192.168.1.20', 3);
|
||||
expect(calls).toEqual([
|
||||
{ method: 'GET', url: 'http://192.168.1.20/api/health' },
|
||||
{
|
||||
@@ -37,6 +39,18 @@ describe('createSafeControlPlaneUpstream', () => {
|
||||
headers: {},
|
||||
body: '',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
url: 'http://192.168.1.20/api/service/restart',
|
||||
headers: {},
|
||||
body: '',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
url: 'http://192.168.1.20/api/system/reboot',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"delay_seconds":3}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import { SafeUpstreamGateway, type SafeUpstreamTransport } from './safe-upstream
|
||||
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||
request: UpstreamSessionClientOptions['request'];
|
||||
postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>;
|
||||
postServiceRestart(origin: string): Promise<{ readonly status: number }>;
|
||||
postSystemReboot(origin: string, delaySeconds: number): Promise<{ readonly status: number }>;
|
||||
}
|
||||
export function createSafeControlPlaneUpstream(
|
||||
transport: SafeUpstreamTransport,
|
||||
@@ -14,5 +16,7 @@ export function createSafeControlPlaneUpstream(
|
||||
get: (url) => transport.get(url),
|
||||
request: (request) => gateway.request(request),
|
||||
postNetworkRegisterAuto: (origin) => gateway.postNetworkRegisterAuto(origin),
|
||||
postServiceRestart: (origin) => gateway.postServiceRestart(origin),
|
||||
postSystemReboot: (origin, delaySeconds) => gateway.postSystemReboot(origin, delaySeconds),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,4 +161,27 @@ describe('SafeUpstreamGateway', () => {
|
||||
}),
|
||||
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
|
||||
});
|
||||
|
||||
it('dispatches audited service restart and system reboot with fixed delay body', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const gateway = new SafeUpstreamGateway({
|
||||
transport: {
|
||||
get: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
post: async (url, headers, body) => {
|
||||
calls.push({ url, headers, body });
|
||||
return { status: 204, headers: {}, body: '' };
|
||||
},
|
||||
},
|
||||
});
|
||||
await gateway.postServiceRestart('http://192.168.1.20:8080');
|
||||
await gateway.postSystemReboot('http://192.168.1.20:8080', 3);
|
||||
expect(calls).toEqual([
|
||||
{ url: 'http://192.168.1.20:8080/api/service/restart', headers: {}, body: '' },
|
||||
{
|
||||
url: 'http://192.168.1.20:8080/api/system/reboot',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"delay_seconds":3}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface SafeUpstreamTransport {
|
||||
}
|
||||
export class SafeUpstreamGateway {
|
||||
constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {}
|
||||
async postNetworkRegisterAuto(origin: string): Promise<UpstreamResponse> {
|
||||
private assertOrigin(origin: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(origin);
|
||||
@@ -32,7 +32,12 @@ export class SafeUpstreamGateway {
|
||||
parsed.hash
|
||||
)
|
||||
throw this.notDispatched();
|
||||
const url = `${parsed.origin}/api/network/register-auto`;
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
private async postZeroBody(origin: string, path: string): Promise<UpstreamResponse> {
|
||||
const base = this.assertOrigin(origin);
|
||||
const url = `${base}${path}`;
|
||||
try {
|
||||
return await this.options.transport.post(url, {}, '');
|
||||
} catch (error) {
|
||||
@@ -44,6 +49,34 @@ export class SafeUpstreamGateway {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async postNetworkRegisterAuto(origin: string): Promise<UpstreamResponse> {
|
||||
return this.postZeroBody(origin, '/api/network/register-auto');
|
||||
}
|
||||
|
||||
async postServiceRestart(origin: string): Promise<UpstreamResponse> {
|
||||
return this.postZeroBody(origin, '/api/service/restart');
|
||||
}
|
||||
|
||||
async postSystemReboot(origin: string, delaySeconds: number): Promise<UpstreamResponse> {
|
||||
if (delaySeconds !== 3) throw this.notDispatched();
|
||||
const base = this.assertOrigin(origin);
|
||||
const url = `${base}/api/system/reboot`;
|
||||
try {
|
||||
return await this.options.transport.post(
|
||||
url,
|
||||
{ 'content-type': 'application/json' },
|
||||
JSON.stringify({ delay_seconds: 3 }),
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof UpstreamError &&
|
||||
(error.code === 'UNSAFE_ORIGIN' || error.code === 'UNSAFE_RESOLUTION')
|
||||
)
|
||||
throw new OperationNotDispatchedError(error.code);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async request(request: UpstreamRequest): Promise<UpstreamResponse> {
|
||||
const url = new URL(request.url);
|
||||
const headerKeys = Object.keys(request.headers).sort().join(',');
|
||||
|
||||
@@ -31,6 +31,8 @@ async function fixtureOptions(): Promise<ProductionControlPlaneOptions> {
|
||||
get: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
request: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
||||
postServiceRestart: async () => ({ status: 200 }),
|
||||
postSystemReboot: async () => ({ status: 200 }),
|
||||
},
|
||||
keychainMetadataCheck: async () => true,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user