feat(api): absorb the Hub control plane into the local instance model

Add central notification channels, rules, queue and delivery logs, fleet
organization groups and tags, device discovery, the device action catalog,
instance module reads, the log centre, connection settings and system
maintenance as native /api/v1 routes backed by the existing secret store,
audit trail and pinned upstream transport.
This commit is contained in:
chick
2026-09-05 18:53:04 +08:00
parent f11877f13e
commit f9186bd851
97 changed files with 18646 additions and 104 deletions
@@ -62,21 +62,30 @@ describe('database migrations', () => {
'app_settings',
'audit_events',
'capabilities',
'connection_logs',
'console_auth_config',
'console_auth_sessions',
'device_groups',
'event_journal',
'instance_tags',
'instances',
'job_attempts',
'job_items',
'jobs',
'notification_channels',
'notification_deliveries',
'notification_queue',
'notification_rules',
'operation_preparations',
'scheduled_runs',
'scheduled_tasks',
'schema_migrations',
'secret_cleanup_tasks',
'secret_references',
'sms_messages',
'sms_outbox',
'status_snapshots',
'tag_registry',
]);
expect(database.pragma('foreign_keys', { simple: true })).toBe(1);
@@ -0,0 +1,103 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { MIGRATIONS, migrateDatabase } from './migrations.js';
function seeded(): Database.Database {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db, MIGRATIONS.slice(0, 10));
db.prepare(
`INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at)
VALUES ('i-1','Node One','http://a','password',1,1,'2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')`,
).run();
db.prepare(
`INSERT INTO instance_tags (instance_id,tag,created_at) VALUES ('i-1','office','2026-01-01T00:00:00.000Z')`,
).run();
db.prepare(
`INSERT INTO scheduled_tasks (id,name,operation_type,enabled,version,cron_expression,timezone,
target_selector_json,sms_secret_reference,sms_recipient_count,effective_start_at,effective_end_at,
misfire_policy,overlap_policy,retry_policy_json,next_due_at,last_evaluated_at,created_by,updated_by,
created_at,updated_at,deleted_at)
VALUES ('t-1','Nightly','reboot-system',1,3,'0 3 * * *','Asia/Shanghai','{"mode":"fixed","instanceIds":["i-1"]}',
NULL,NULL,NULL,NULL,'skip','skip','{"maxRetries":2,"intervalSeconds":60}',
'2026-09-04T03:00:00.000Z',NULL,'sys','sys','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z',NULL)`,
).run();
db.prepare(
`INSERT INTO scheduled_runs (id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,claimed_at,
started_at,finished_at,target_snapshot_json,outcome,reason,job_ids_json,trigger_source,attempt)
VALUES ('r-1','t-1',3,'{}','2026-09-03T03:00:00.000Z','2026-09-03T03:00:00.000Z',
'2026-09-03T03:00:01.000Z','2026-09-03T03:00:02.000Z','["i-1"]','succeeded',NULL,'[]','scheduled',1)`,
).run();
return db;
}
describe('migration upgrade path', () => {
it('preserves automation rows and registers existing tags when upgrading from migration 10', () => {
const db = seeded();
expect(() => migrateDatabase(db)).not.toThrow();
const task = db.prepare('SELECT * FROM scheduled_tasks WHERE id=?').get('t-1') as Record<
string,
unknown
>;
expect(task).toMatchObject({ name: 'Nightly', operation_type: 'reboot-system', version: 3 });
expect(task.trigger_json).toBeNull();
const run = db.prepare('SELECT * FROM scheduled_runs WHERE id=?').get('r-1') as Record<
string,
unknown
>;
expect(run).toMatchObject({ scheduled_task_id: 't-1', outcome: 'succeeded' });
expect(db.prepare('SELECT tag FROM tag_registry ORDER BY tag').all()).toEqual([
{ tag: 'office' },
]);
expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]);
db.prepare(
`INSERT INTO scheduled_tasks (id,name,operation_type,enabled,version,cron_expression,timezone,
target_selector_json,sms_secret_reference,sms_recipient_count,effective_start_at,effective_end_at,
misfire_policy,overlap_policy,retry_policy_json,next_due_at,last_evaluated_at,created_by,updated_by,
created_at,updated_at,deleted_at,trigger_json)
VALUES ('t-2','Baseband','restart-baseband',1,1,'0 4 * * *','Asia/Shanghai','{"mode":"all"}',
NULL,NULL,NULL,NULL,'skip','skip','{"maxRetries":0,"intervalSeconds":60}',NULL,NULL,'sys','sys',
'2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z',NULL,'{"kind":"interval","value":6,"unit":"hours"}')`,
).run();
expect(
(
db.prepare('SELECT trigger_json FROM scheduled_tasks WHERE id=?').get('t-2') as {
trigger_json: string;
}
).trigger_json,
).toContain('interval');
const names = (
db
.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_scheduled%' ORDER BY name",
)
.all() as Array<{
name: string;
}>
).map((row) => row.name);
expect(names).toEqual([
'idx_scheduled_runs_outcome_finished',
'idx_scheduled_runs_task_due',
'idx_scheduled_tasks_enabled_next_due',
]);
db.close();
});
it('applies cleanly to an empty database', () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]);
const applied = db.prepare('SELECT id,name FROM schema_migrations ORDER BY id').all() as Array<{
id: number;
name: string;
}>;
// The ledger must match the declared migration list exactly, in order, so a new migration
// can never be added without being applied here.
expect(applied).toEqual(
MIGRATIONS.map((migration) => ({ id: migration.id, name: migration.name })),
);
db.close();
});
});
@@ -356,6 +356,288 @@ export const MIGRATIONS: readonly Migration[] = [
'CREATE INDEX idx_scheduled_runs_outcome_finished ON scheduled_runs(outcome, finished_at DESC)',
],
},
{
id: 9,
name: 'central-notifications',
statements: [
`CREATE TABLE notification_channels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN (
'webhook','bark','pushplus','wecom_app','wecom_robot','dingtalk_robot','dingtalk_app',
'feishu_robot','telegram','email','serverchan'
)),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
config_json TEXT NOT NULL,
secret_reference TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE notification_rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
event_type TEXT NOT NULL CHECK (event_type IN (
'sms','ddns','version','system','device','automation'
)),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
condition_json TEXT NOT NULL,
scope_json TEXT NOT NULL,
channel_ids_json TEXT NOT NULL,
templates_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE notification_deliveries (
id TEXT PRIMARY KEY,
rule_id TEXT REFERENCES notification_rules(id) ON DELETE SET NULL,
channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
instance_id TEXT,
event_type TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN (
'success','failed','pending','sending','retrying','unmatched','no_available_channel','quiet_hours'
)),
detail TEXT,
created_at TEXT NOT NULL,
sent_at TEXT
)`,
'CREATE INDEX idx_notification_deliveries_created_at ON notification_deliveries(created_at DESC)',
'CREATE INDEX idx_notification_deliveries_status_created_at ON notification_deliveries(status, created_at DESC)',
],
},
{
id: 10,
name: 'hub-message-and-notification-queue-persistence',
statements: [
`CREATE TABLE sms_messages (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL,
upstream_id TEXT NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('incoming','outgoing','received','sent','unknown')),
phone_number TEXT NOT NULL,
content TEXT NOT NULL,
timestamp TEXT NOT NULL,
status TEXT NOT NULL,
transport TEXT NOT NULL,
synced_at TEXT NOT NULL,
UNIQUE (instance_id, upstream_id)
)`,
'CREATE INDEX idx_sms_messages_instance_timestamp ON sms_messages(instance_id, timestamp DESC)',
'CREATE INDEX idx_sms_messages_timestamp ON sms_messages(timestamp DESC)',
'CREATE INDEX idx_sms_messages_phone_timestamp ON sms_messages(phone_number, timestamp DESC)',
`CREATE TABLE notification_queue (
id TEXT PRIMARY KEY,
instance_id TEXT,
event_type TEXT NOT NULL,
rule_id TEXT REFERENCES notification_rules(id) ON DELETE SET NULL,
channel_id TEXT REFERENCES notification_channels(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending','sending','succeeded','failed','cancelled'
)),
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
max_attempts INTEGER NOT NULL DEFAULT 3 CHECK (max_attempts > 0),
payload_json TEXT NOT NULL,
last_error TEXT,
available_at TEXT NOT NULL,
delivered_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
'CREATE INDEX idx_notification_queue_status_available_at ON notification_queue(status, available_at)',
'CREATE INDEX idx_notification_queue_created_at ON notification_queue(created_at DESC)',
],
},
{
id: 11,
name: 'hub-groups-tags-and-automation-triggers',
statements: [
`CREATE TABLE device_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
'ALTER TABLE instances ADD COLUMN group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL',
'CREATE INDEX idx_instances_group_id ON instances(group_id)',
`CREATE TABLE tag_registry (
tag TEXT PRIMARY KEY,
color TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`INSERT INTO tag_registry (tag, created_at, updated_at)
SELECT tag, MIN(created_at), MAX(created_at) FROM instance_tags GROUP BY tag`,
// scheduled_tasks carries an operation_type CHECK that has to widen, so both the
// task table and its only child are rebuilt; renaming the child first keeps the
// RESTRICT edge from firing while the parent is swapped out.
'ALTER TABLE scheduled_runs RENAME TO scheduled_runs_old',
'ALTER TABLE scheduled_tasks RENAME TO scheduled_tasks_old',
`CREATE TABLE scheduled_tasks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
operation_type TEXT NOT NULL CHECK (operation_type IN (
'restart-service','reboot-system','send-sms','restart-baseband','backup-data'
)),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
cron_expression TEXT NOT NULL,
timezone TEXT NOT NULL CHECK (timezone = 'Asia/Shanghai'),
target_selector_json TEXT NOT NULL,
sms_secret_reference TEXT,
sms_recipient_count INTEGER CHECK (sms_recipient_count IS NULL OR sms_recipient_count > 0),
effective_start_at TEXT,
effective_end_at TEXT,
misfire_policy TEXT NOT NULL CHECK (misfire_policy IN ('skip','catch-up-once')),
overlap_policy TEXT NOT NULL CHECK (overlap_policy IN ('skip','queue-once')),
retry_policy_json TEXT NOT NULL,
next_due_at TEXT,
last_evaluated_at TEXT,
created_by TEXT NOT NULL,
updated_by TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
trigger_json TEXT,
CHECK ((operation_type = 'send-sms') = (sms_secret_reference IS NOT NULL)),
CHECK ((sms_secret_reference IS NULL) = (sms_recipient_count IS NULL))
)`,
`CREATE TABLE scheduled_runs (
id TEXT PRIMARY KEY,
scheduled_task_id TEXT NOT NULL REFERENCES scheduled_tasks(id) ON DELETE RESTRICT,
schedule_version INTEGER NOT NULL CHECK (schedule_version > 0),
task_snapshot_json TEXT NOT NULL,
due_at TEXT NOT NULL,
claimed_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
target_snapshot_json TEXT NOT NULL,
outcome TEXT CHECK (outcome IS NULL OR outcome IN ('succeeded','partially-succeeded','failed','skipped','no-targets','needs-attention')),
reason TEXT,
job_ids_json TEXT NOT NULL DEFAULT '[]',
trigger_source TEXT NOT NULL CHECK (trigger_source IN ('scheduled','manual')),
attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt > 0),
UNIQUE (scheduled_task_id, schedule_version, due_at, trigger_source)
)`,
`INSERT INTO scheduled_tasks (id,name,operation_type,enabled,version,cron_expression,timezone,
target_selector_json,sms_secret_reference,sms_recipient_count,effective_start_at,
effective_end_at,misfire_policy,overlap_policy,retry_policy_json,next_due_at,
last_evaluated_at,created_by,updated_by,created_at,updated_at,deleted_at,trigger_json)
SELECT id,name,operation_type,enabled,version,cron_expression,timezone,target_selector_json,
sms_secret_reference,sms_recipient_count,effective_start_at,effective_end_at,misfire_policy,
overlap_policy,retry_policy_json,next_due_at,last_evaluated_at,created_by,updated_by,
created_at,updated_at,deleted_at,NULL FROM scheduled_tasks_old`,
`INSERT INTO scheduled_runs (id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,
claimed_at,started_at,finished_at,target_snapshot_json,outcome,reason,job_ids_json,
trigger_source,attempt)
SELECT id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,claimed_at,started_at,
finished_at,target_snapshot_json,outcome,reason,job_ids_json,trigger_source,attempt
FROM scheduled_runs_old`,
'DROP TABLE scheduled_runs_old',
'DROP TABLE scheduled_tasks_old',
'CREATE INDEX idx_scheduled_tasks_enabled_next_due ON scheduled_tasks(enabled, next_due_at) WHERE deleted_at IS NULL',
'CREATE INDEX idx_scheduled_runs_task_due ON scheduled_runs(scheduled_task_id, due_at DESC)',
'CREATE INDEX idx_scheduled_runs_outcome_finished ON scheduled_runs(outcome, finished_at DESC)',
],
},
{
id: 12,
name: 'schedule-action-delay',
statements: [
`ALTER TABLE scheduled_tasks ADD COLUMN delay_seconds INTEGER
CHECK (delay_seconds IS NULL OR (delay_seconds >= 0 AND delay_seconds <= 3600))`,
],
},
{
id: 13,
name: 'notification-rule-rate-limit-and-quiet-hours',
statements: [
`ALTER TABLE notification_rules ADD COLUMN rate_limit_json TEXT NOT NULL DEFAULT
'{"enabled":false,"maxMessages":20,"windowSeconds":60}'`,
`ALTER TABLE notification_rules ADD COLUMN quiet_hours_json TEXT NOT NULL DEFAULT '[]'`,
// Suppressions (quiet hours, rate limit) are attributed to a rule rather than a channel,
// so channel_id becomes optional. The status CHECK also has to widen, which means the
// table is rebuilt; nothing references notification_deliveries, so the swap is contained.
`CREATE TABLE notification_deliveries_new (
id TEXT PRIMARY KEY,
rule_id TEXT REFERENCES notification_rules(id) ON DELETE SET NULL,
channel_id TEXT REFERENCES notification_channels(id) ON DELETE CASCADE,
instance_id TEXT,
event_type TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN (
'success','failed','pending','sending','retrying','unmatched','no_available_channel',
'quiet_hours','rate_limited'
)),
detail TEXT,
created_at TEXT NOT NULL,
sent_at TEXT
)`,
`INSERT INTO notification_deliveries_new
(id,rule_id,channel_id,instance_id,event_type,status,detail,created_at,sent_at)
SELECT id,rule_id,channel_id,instance_id,event_type,status,detail,created_at,sent_at
FROM notification_deliveries`,
'DROP TABLE notification_deliveries',
'ALTER TABLE notification_deliveries_new RENAME TO notification_deliveries',
'CREATE INDEX idx_notification_deliveries_created_at ON notification_deliveries(created_at DESC)',
'CREATE INDEX idx_notification_deliveries_status_created_at ON notification_deliveries(status, created_at DESC)',
],
},
{
id: 14,
name: 'connection-log-history',
statements: [
// The Hub keeps a rolling connection history per device; the snapshot table only holds
// the latest probe, so outcomes need their own bounded journal.
`CREATE TABLE connection_logs (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
outcome TEXT NOT NULL CHECK (outcome IN ('success','stale','failed','unsupported')),
state TEXT NOT NULL CHECK (state IN ('fresh','stale','expired','unknown')),
error_code TEXT,
http_status INTEGER,
duration_ms INTEGER NOT NULL CHECK (duration_ms >= 0),
observed_at TEXT NOT NULL
)`,
'CREATE INDEX idx_connection_logs_observed_at ON connection_logs(observed_at DESC)',
'CREATE INDEX idx_connection_logs_instance_observed_at ON connection_logs(instance_id, observed_at DESC)',
'CREATE INDEX idx_connection_logs_outcome_observed_at ON connection_logs(outcome, observed_at DESC)',
],
},
{
id: 15,
name: 'sms-offline-outbox',
statements: [
// The Hub keeps sending while a device is offline and delivers when it returns; the
// central archive alone cannot hold a write, so outbound SMS need their own queue.
`CREATE TABLE sms_outbox (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
phone_number TEXT NOT NULL,
content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN (
'queued','sending','sent','failed','cancelled'
)),
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
max_attempts INTEGER NOT NULL DEFAULT 24 CHECK (max_attempts > 0),
last_error TEXT,
available_at TEXT NOT NULL,
sent_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
'CREATE INDEX idx_sms_outbox_status_available_at ON sms_outbox(status, available_at)',
'CREATE INDEX idx_sms_outbox_instance_status ON sms_outbox(instance_id, status)',
'CREATE INDEX idx_sms_outbox_created_at ON sms_outbox(created_at DESC)',
],
},
{
id: 16,
name: 'notification-channel-secret-fields',
statements: [
// Hub channels keep several credentials (DingTalk needs an access token *and* a signing
// key), so the secret store holds a JSON map and the row records which keys it covers.
"ALTER TABLE notification_channels ADD COLUMN secret_fields TEXT NOT NULL DEFAULT ''",
],
},
];
const createMigrationsTable = `CREATE TABLE schema_migrations (
@@ -10,7 +10,7 @@ import { asUpstreamError, UpstreamError } from './upstream-error.js';
export interface PinnedDispatchOptions {
readonly url: URL;
readonly method: 'GET' | 'POST';
readonly method: 'GET' | 'POST' | 'DELETE';
readonly headers: Readonly<Record<string, string>>;
readonly body?: string;
readonly lookup: LookupFunction;
@@ -6,6 +6,7 @@ export interface SafeControlPlaneUpstream extends ConnectionTransport {
request: UpstreamSessionClientOptions['request'];
postNetworkRegisterAuto(origin: string, cookie?: string): Promise<{ readonly status: number }>;
postServiceRestart(origin: string, cookie?: string): Promise<{ readonly status: number }>;
postBasebandRestart(origin: string, cookie?: string): Promise<{ readonly status: number }>;
postSystemReboot(
origin: string,
delaySeconds: number,
@@ -21,6 +22,7 @@ export function createSafeControlPlaneUpstream(
request: (request) => gateway.request(request),
postNetworkRegisterAuto: (origin, cookie) => gateway.postNetworkRegisterAuto(origin, cookie),
postServiceRestart: (origin, cookie) => gateway.postServiceRestart(origin, cookie),
postBasebandRestart: (origin, cookie) => gateway.postBasebandRestart(origin, cookie),
postSystemReboot: (origin, delaySeconds, cookie) =>
gateway.postSystemReboot(origin, delaySeconds, cookie),
};
@@ -12,7 +12,7 @@ export interface TransportResponse {
}
export interface PinnedRequest {
readonly url: string;
readonly method: 'GET' | 'POST';
readonly method: 'GET' | 'POST' | 'DELETE';
readonly headers: Readonly<Record<string, string>>;
readonly body?: string;
}
@@ -80,6 +80,12 @@ export class SafeInstanceTransport {
): Promise<TransportResponse> {
return this.send({ url: raw, method: 'POST', headers, body });
}
async delete(
raw: string,
headers: Readonly<Record<string, string>> = {},
): Promise<TransportResponse> {
return this.send({ url: raw, method: 'DELETE', headers });
}
private async send(request: PinnedRequest): Promise<TransportResponse> {
const parsed = origin(request.url);
const dialHost = parsed.hostname.replace(/^\[|\]$/g, '');
@@ -1,5 +1,14 @@
import { describe, expect, it, vi } from 'vitest';
import { SafeUpstreamGateway } from './safe-upstream-gateway.js';
import type { TransportResponse } from './safe-instance-transport.js';
/** Spies typed with the transport signature so `mock.calls` keeps its argument tuple. */
type GetSpy = (url: string, headers: Record<string, string>) => Promise<TransportResponse>;
type PostSpy = (
url: string,
headers: Record<string, string>,
body: string,
) => Promise<TransportResponse>;
describe('SafeUpstreamGateway', () => {
it('dispatches only the audited zero-body network registration operation through pinned POST', async () => {
@@ -135,6 +144,48 @@ describe('SafeUpstreamGateway', () => {
});
});
it('allows bounded device, network and cellular-signal reads', async () => {
const get = vi.fn<GetSpy>(async () => ({
status: 200,
headers: {},
body: '{}',
}));
const gateway = new SafeUpstreamGateway({ transport: { get, post: vi.fn() } });
for (const path of ['/api/device', '/api/network', '/api/network/signal-strength']) {
await gateway.request({
url: `http://192.168.1.20:8080${path}`,
method: 'GET',
headers: { accept: 'application/json' },
});
}
expect(
get.mock.calls.map(([url]) => String(url).replace('http://192.168.1.20:8080', '')),
).toEqual(['/api/device', '/api/network', '/api/network/signal-strength']);
for (const request of [
{
url: 'http://192.168.1.20:8080/api/network?refresh=true',
method: 'GET' as const,
headers: { accept: 'application/json' },
},
{
url: 'http://192.168.1.20:8080/api/device',
method: 'GET' as const,
headers: { accept: 'application/json', cookie: 'simadmin_session=bad;other=1' },
},
{
url: 'http://192.168.1.20:8080/api/device',
method: 'GET' as const,
headers: { accept: 'application/json' },
secret: '[REDACTED]',
},
]) {
await expect(gateway.request(request)).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
}
});
it('allows only a bounded SMS list query and serializes an explicit SMS payload', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
@@ -158,6 +209,26 @@ describe('SafeUpstreamGateway', () => {
);
});
it('serializes an explicit SMS batch-delete payload', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({ transport: { get, post } });
await gateway.request({
url: 'http://192.168.1.20:8080/api/sms/batch-delete',
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
smsBatchDelete: { ids: [987, 654, 0] },
});
expect(post).toHaveBeenCalledWith(
'http://192.168.1.20:8080/api/sms/batch-delete',
{ accept: 'application/json', 'content-type': 'application/json' },
'{"ids":[987,654,0]}',
);
expect(get).not.toHaveBeenCalled();
});
it('dispatches an SMS at the 2,000-character automation limit', async () => {
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({
@@ -199,6 +270,50 @@ describe('SafeUpstreamGateway', () => {
expect(post).not.toHaveBeenCalled();
});
it('rejects malformed SMS batch-delete payloads before transport', async () => {
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({
transport: { get: vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })), post },
});
const base = {
url: 'http://192.168.1.20:8080/api/sms/batch-delete',
method: 'POST' as const,
headers: { accept: 'application/json', 'content-type': 'application/json' },
};
await expect(gateway.request({ ...base, smsBatchDelete: { ids: [] } })).rejects.toThrow(
'UPSTREAM_REQUEST_INVALID',
);
await expect(
gateway.request({
...base,
smsBatchDelete: { ids: [1, -1] },
}),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
await expect(
gateway.request({
...base,
headers: { accept: 'application/json' },
smsBatchDelete: { ids: [1] },
}),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
await expect(
gateway.request({
...base,
smsBatchDelete: { ids: [1] },
body: '[REDACTED]',
}),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/sms/batch-delete?limit=1',
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
smsBatchDelete: { ids: [1] },
}),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
expect(post).not.toHaveBeenCalled();
});
it('does not allow a supplied redacted body marker to become a network request body', async () => {
const gateway = new SafeUpstreamGateway({
transport: {
@@ -269,4 +384,225 @@ describe('SafeUpstreamGateway', () => {
gateway.postServiceRestart('http://192.168.1.20:8080', 'simadmin_session=bad; extra'),
).rejects.toMatchObject({ code: 'UPSTREAM_REQUEST_INVALID', dispatched: false });
});
it('sends a parseable empty JSON document when restarting the device baseband', 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: 200, headers: {}, body: '{}' };
},
},
});
await gateway.postBasebandRestart('http://192.168.1.20:8080', 'simadmin_session=opaque-token');
expect(calls).toEqual([
{
url: 'http://192.168.1.20:8080/api/baseband/restart',
headers: { 'content-type': 'application/json', cookie: 'simadmin_session=opaque-token' },
body: '{}',
},
]);
await expect(
gateway.postBasebandRestart('https://device.example.test/api/baseband/restart'),
).rejects.toMatchObject({ dispatched: false });
});
it('dispatches every console device-module read through the pinned GET transport', async () => {
const get = vi.fn<GetSpy>(async () => ({
status: 200,
headers: {},
body: '{}',
}));
const gateway = new SafeUpstreamGateway({ transport: { get, post: vi.fn() } });
const paths = [
'/api/connectivity',
'/api/band-lock',
'/api/cell-lock',
'/api/work-mode',
'/api/hub',
'/api/auth/settings',
'/api/call/history?limit=25',
'/api/esim/profiles?cached=1',
'/api/esim/euicc?live=1',
'/api/backup/options',
'/api/notifications/queue?limit=50',
'/api/automation/logs',
'/api/ota/status',
'/api/vowifi/profiles',
'/api/vowifi/diagnostics?limit=50',
'/api/vowifi/events?limit=50',
'/api/vowifi/soak?limit=20',
'/api/vowifi/sms/delivery?limit=20',
'/api/vowifi/esim-restore/status',
'/api/device-network/wlan/profiles',
];
for (const path of paths) {
await gateway.request({
url: `http://192.168.1.20:8080${path}`,
method: 'GET',
headers: { accept: 'application/json' },
});
}
const requested = get.mock.calls.map(([url]) =>
String(url).replace('http://192.168.1.20:8080', ''),
);
expect(requested).toEqual(paths);
});
it('rejects a query shape the module catalog never emits', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({ transport: { get, post: vi.fn() } });
for (const url of [
'http://192.168.1.20:8080/api/band-lock?refresh=true',
'http://192.168.1.20:8080/api/notifications/queue?limit=9999',
'http://192.168.1.20:8080/api/call/history',
'http://192.168.1.20:8080/api/unknown/module',
]) {
await expect(
gateway.request({ url, method: 'GET', headers: { accept: 'application/json' } }),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
}
expect(get).not.toHaveBeenCalled();
});
it('serializes an allowlisted device action body canonically', async () => {
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({ transport: { get: vi.fn(), post } });
await gateway.request({
url: 'http://192.168.1.20:8080/api/band-lock',
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
deviceAction: { body: { nr_tdd_bands: ['41'], lte_fdd_bands: ['3'] } },
});
expect(post).toHaveBeenCalledWith(
'http://192.168.1.20:8080/api/band-lock',
{ accept: 'application/json', 'content-type': 'application/json' },
'{"lte_fdd_bands":["3"],"nr_tdd_bands":["41"]}',
);
});
it('dispatches zero-body device actions, parametric paths, and deletions', async () => {
const post = vi.fn<PostSpy>(async () => ({
status: 200,
headers: {},
body: '{}',
}));
const del = vi.fn<GetSpy>(async () => ({
status: 200,
headers: {},
body: '{}',
}));
const gateway = new SafeUpstreamGateway({ transport: { get: vi.fn(), post, delete: del } });
await gateway.request({
url: 'http://192.168.1.20:8080/api/cell-lock/unlock-all',
method: 'POST',
headers: { accept: 'application/json' },
deviceAction: { body: {} },
});
await gateway.request({
url: 'http://192.168.1.20:8080/api/esim/profiles/89882020202220963176/rename',
method: 'POST',
headers: { 'content-type': 'application/json' },
deviceAction: { body: { name: 'work' } },
});
await gateway.request({
url: 'http://192.168.1.20:8080/api/backup/files/backup-2026.tar.gz',
method: 'DELETE',
headers: { accept: 'application/json' },
deviceAction: { body: undefined },
});
expect(post.mock.calls.map((call) => [call[0], call[2]])).toEqual([
['http://192.168.1.20:8080/api/cell-lock/unlock-all', '{}'],
['http://192.168.1.20:8080/api/esim/profiles/89882020202220963176/rename', '{"name":"work"}'],
]);
expect(del).toHaveBeenCalledWith(
'http://192.168.1.20:8080/api/backup/files/backup-2026.tar.gz',
{ accept: 'application/json' },
);
});
it('refuses device actions outside the allowlist or with an unshapeable body', async () => {
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({ transport: { get: vi.fn(), post } });
const headers = { accept: 'application/json', 'content-type': 'application/json' };
const cases: {
url: string;
deviceAction: { body: Readonly<Record<string, unknown>> | undefined };
}[] = [
{ url: 'http://192.168.1.20:8080/api/system/reboot', deviceAction: { body: {} } },
{ url: 'http://192.168.1.20:8080/api/apn', deviceAction: { body: { nested: { deep: 1 } } } },
{ url: 'http://192.168.1.20:8080/api/apn', deviceAction: { body: { 'Bad Key': 'x' } } },
{ url: 'http://192.168.1.20:8080/api/apn', deviceAction: { body: { apn: 'a\u0001b' } } },
{
url: 'http://192.168.1.20:8080/api/apn',
deviceAction: { body: { apn: 'x'.repeat(600) } },
},
{ url: 'http://192.168.1.20:8080/api/apn', deviceAction: { body: { apn: [1, 2] } } },
];
for (const entry of cases) {
await expect(gateway.request({ ...entry, method: 'POST', headers })).rejects.toThrow(
'UPSTREAM_REQUEST_INVALID',
);
}
expect(post).not.toHaveBeenCalled();
});
it('never lets a device action borrow another request marker', async () => {
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({ transport: { get, post } });
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/apn',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '[REDACTED]',
deviceAction: { body: { apn: 'internet' } },
}),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/apn',
method: 'GET',
headers: { accept: 'application/json' },
deviceAction: { body: { apn: 'internet' } },
}),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/backup/files/backup-2026.tar.gz',
method: 'DELETE',
headers: { accept: 'application/json' },
deviceAction: { body: undefined },
}),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
expect(post).not.toHaveBeenCalled();
expect(get).not.toHaveBeenCalled();
});
it('forwards the session cookie on a device action but rejects a foreign cookie shape', async () => {
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({ transport: { get: vi.fn(), post } });
await gateway.request({
url: 'http://192.168.1.20:8080/api/radio-mode',
method: 'POST',
headers: { 'content-type': 'application/json', cookie: 'simadmin_session=opaque' },
deviceAction: { body: { mode: 'lte' } },
});
expect(post).toHaveBeenCalledWith(
'http://192.168.1.20:8080/api/radio-mode',
{ 'content-type': 'application/json', cookie: 'simadmin_session=opaque' },
'{"mode":"lte"}',
);
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/radio-mode',
method: 'POST',
headers: { 'content-type': 'application/json', cookie: 'simadmin_session=a; csrf=b' },
deviceAction: { body: { mode: 'lte' } },
}),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
});
});
@@ -13,6 +13,7 @@ export interface SafeUpstreamTransport {
headers: Readonly<Record<string, string>>,
body: string,
): Promise<TransportResponse>;
delete?(url: string, headers: Readonly<Record<string, string>>): Promise<TransportResponse>;
}
/** Literal private hosts only — hostnames that still need DNS stay HTTPS for auth secrets. */
@@ -33,6 +34,228 @@ const isLiteralPrivateHost = (hostname: string): boolean => {
);
};
/**
* Every device read the console modules perform, pinned to the exact query shape the module
* catalog emits. A device module can only ever widen this list through a code review, never
* through a value that came back from the network.
*/
const DEVICE_READ_PATHS: Readonly<Record<string, RegExp>> = {
'/api/health': /^$/u,
'/api/device': /^$/u,
'/api/sim': /^$/u,
'/api/network': /^$/u,
'/api/stats': /^$/u,
'/api/stats/cpu': /^$/u,
'/api/connectivity': /^$/u,
'/api/data': /^$/u,
'/api/apn': /^$/u,
'/api/band-lock': /^$/u,
'/api/cell-lock': /^$/u,
'/api/calls': /^$/u,
'/api/cell-monitor/status': /^$/u,
'/api/cells': /^$/u,
'/api/location/cell-info': /^$/u,
'/api/roaming': /^$/u,
'/api/radio-mode': /^$/u,
'/api/airplane-mode': /^$/u,
'/api/work-mode': /^$/u,
'/api/hub': /^$/u,
'/api/auth/settings': /^$/u,
'/api/auth/status': /^$/u,
'/api/baseband/restart/status': /^$/u,
'/api/call/history': /^limit=(?:[1-9]|[1-9][0-9]|100)$/u,
'/api/call/settings': /^$/u,
'/api/call/forwarding': /^$/u,
'/api/call/volume': /^$/u,
'/api/voicemail/status': /^$/u,
'/api/ims/status': /^$/u,
'/api/network/operators': /^$/u,
'/api/network/signal-strength': /^$/u,
'/api/network/interfaces': /^$/u,
'/api/network/connection-addresses': /^$/u,
'/api/device-network/wlan/status': /^$/u,
'/api/device-network/wlan/profiles': /^$/u,
'/api/device-network/ddns/status': /^$/u,
'/api/device-network/ddns/config': /^$/u,
'/api/device-network/ddns/logs': /^$/u,
'/api/esim/euicc': /^(?:live=1)?$/u,
'/api/esim/profiles': /^(?:cached=1)?$/u,
'/api/esim/config': /^$/u,
'/api/esim/lpac/status': /^$/u,
'/api/backup/files': /^$/u,
'/api/backup/config': /^$/u,
'/api/backup/options': /^$/u,
'/api/notifications/config': /^$/u,
'/api/notifications/queue': /^limit=(?:[1-9]|[1-9][0-9]|100)$/u,
'/api/notifications/logs': /^limit=(?:[1-9]|[1-9][0-9]|100)$/u,
'/api/automation/config': /^$/u,
'/api/automation/logs': /^$/u,
'/api/ota/status': /^$/u,
'/api/sms/conversation':
/^phone_number=%2B?[0-9][0-9 ()-]{1,30}&limit=(?:[1-9]|[1-9][0-9]|100)$/u,
'/api/vowifi/status': /^$/u,
'/api/vowifi/control': /^$/u,
'/api/vowifi/profile': /^$/u,
'/api/vowifi/profiles': /^$/u,
// VoWiFi diagnostics: one aggregate read plus the narrower feeds the Hub polls separately.
'/api/vowifi/diagnostics':
/^(?:|limit=(?:[1-9]|[1-9][0-9]|100)|trace_id=[A-Za-z0-9_.:-]{1,64}|limit=(?:[1-9]|[1-9][0-9]|100)&trace_id=[A-Za-z0-9_.:-]{1,64})$/u,
'/api/vowifi/events': /^limit=(?:[1-9]|[1-9][0-9]|100)$/u,
'/api/vowifi/soak': /^limit=(?:[1-9]|[1-9][0-9]|100)$/u,
'/api/vowifi/sms/delivery': /^limit=(?:[1-9]|[1-9][0-9]|100)$/u,
'/api/vowifi/esim-restore/status': /^$/u,
'/api/sms/stats': /^$/u,
};
/** Device mutations the console may dispatch; each entry is a fixed path with a fixed shape. */
const DEVICE_ACTION_POSTS: Readonly<Record<string, DeviceActionShape>> = {
'/api/sim/details/refresh': 'empty',
'/api/sim/cache': 'json',
'/api/band-lock': 'json',
'/api/cell-lock': 'json',
'/api/cell-lock/unlock-all': 'empty',
'/api/apn': 'json',
'/api/radio-mode': 'json',
'/api/roaming': 'json',
'/api/airplane-mode': 'json',
'/api/data': 'json',
'/api/work-mode': 'json',
'/api/auth/settings': 'json',
'/api/auth/password': 'json',
'/api/network/register-auto': 'empty',
'/api/hub': 'json',
'/api/hub/unbind': 'empty',
'/api/baseband/restart': 'empty',
'/api/cell-monitor/start': 'empty',
'/api/cell-monitor/stop': 'empty',
'/api/call/dial': 'json',
'/api/call/answer': 'json',
'/api/call/hangup': 'json',
'/api/call/hangup-all': 'empty',
'/api/call/settings': 'json',
'/api/call/forwarding': 'json',
'/api/call/volume': 'json',
'/api/call/history/clear': 'empty',
'/api/network/register-manual': 'json',
'/api/network/operators/scan': 'empty',
'/api/device-network/wlan/enabled': 'json',
'/api/device-network/wlan/scan': 'empty',
'/api/device-network/wlan/connect': 'json',
'/api/device-network/wlan/disconnect': 'empty',
'/api/device-network/wlan/forget': 'json',
'/api/device-network/wlan/profile': 'json',
'/api/device-network/ddns/sync': 'empty',
'/api/device-network/ddns/config': 'json',
'/api/device-network/ddns/logs/clear': 'empty-json',
'/api/esim/config': 'json',
'/api/esim/lpac/repair': 'json',
'/api/esim/profiles': 'json',
'/api/vowifi/feature': 'json',
'/api/vowifi/connection': 'json',
'/api/vowifi/connect': 'empty',
'/api/backup/config': 'json',
'/api/backup/export-local': 'json',
'/api/backup/data/clear': 'json',
'/api/notifications/config': 'json',
'/api/notifications/logs/clear': 'empty-json',
'/api/notifications/queue/clear': 'empty',
'/api/notifications/queue/retry-all': 'empty',
'/api/automation/config': 'json',
'/api/automation/logs/clear': 'empty-json',
'/api/ota/cancel': 'empty',
'/api/ota/apply': 'json',
'/api/ota/latest-release': 'json',
'/api/ota/online-prepare': 'json',
'/api/sms/clear': 'empty',
};
/** Actions whose device path carries one opaque identifier segment, such as an ICCID. */
const DEVICE_ACTION_PARAMETRIC_POSTS: readonly {
readonly pattern: RegExp;
readonly shape: DeviceActionShape;
readonly query?: RegExp;
}[] = [
{ pattern: /^\/api\/esim\/profiles\/[A-Za-z0-9_.\-]{1,128}\/enable$/u, shape: 'empty' },
{ pattern: /^\/api\/esim\/profiles\/[A-Za-z0-9_.\-]{1,128}\/rename$/u, shape: 'json' },
{ pattern: /^\/api\/notifications\/test\/[A-Za-z0-9_.\-]{1,64}$/u, shape: 'empty' },
{ pattern: /^\/api\/automation\/test\/[A-Za-z0-9_.\-]{1,64}$/u, shape: 'empty' },
{
pattern: /^\/api\/backup\/files\/[A-Za-z0-9_.\-]{1,128}\/apply$/u,
shape: 'empty',
query: /^mode=(?:replace|merge)&components=[A-Za-z0-9_,.\-]{1,512}$/u,
},
];
const DEVICE_ACTION_PARAMETRIC_DELETES: readonly RegExp[] = [
/^\/api\/backup\/files\/[A-Za-z0-9_.\-]{1,128}$/u,
/^\/api\/esim\/profiles\/[A-Za-z0-9_.\-]{1,128}$/u,
];
/** The one device mutation that carries a query string: a restore mode plus a component list. */
const DEVICE_ACTION_QUERY_POSTS: readonly {
readonly path: RegExp;
readonly query: RegExp;
}[] = [
{
path: /^\/api\/backup\/files\/[A-Za-z0-9_.\-]{1,128}\/apply$/u,
query: /^mode=(?:replace|merge)&components=[A-Za-z0-9_,.\-]{1,512}$/u,
},
];
const ACTION_BODY_KEY = /^[a-z][a-z0-9_]{0,31}$/u;
const MAX_ACTION_BODY_KEYS = 16;
const MAX_ACTION_BODY_BYTES = 4_096;
/**
* `empty` sends no body, `json` requires a caller-supplied object, and `empty-json` always ships a
* literal `{}` document, which is what some device endpoints expect even though they take no input.
*/
type DeviceActionShape = 'empty' | 'json' | 'empty-json';
const isRecordLike = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
/** Resolves the fixed payload shape a device mutation path accepts, or undefined if unknown. */
function deviceActionShape(pathname: string): DeviceActionShape | undefined {
const literal = DEVICE_ACTION_POSTS[pathname];
if (literal !== undefined) return literal;
for (const entry of DEVICE_ACTION_PARAMETRIC_POSTS)
if (entry.pattern.test(pathname)) return entry.shape;
return undefined;
}
/** Only flat, bounded JSON is ever handed to a device; anything else never leaves the process. */
function canonicalActionBody(value: unknown): string | undefined {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length > MAX_ACTION_BODY_KEYS) return undefined;
const sorted: [string, unknown][] = entries
.filter(([key]) => ACTION_BODY_KEY.test(key))
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
if (sorted.length !== entries.length) return undefined;
const output: Record<string, string | number | boolean | null | readonly string[]> = {};
for (const [key, entry] of sorted) {
if (typeof entry === 'string') {
if (entry.length > 512 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(entry))
return undefined;
output[key] = entry;
} else if (typeof entry === 'number') {
if (!Number.isSafeInteger(entry) || Math.abs(entry) > 1_000_000_000) return undefined;
output[key] = entry;
} else if (typeof entry === 'boolean') output[key] = entry;
else if (entry === null) output[key] = null;
else if (
Array.isArray(entry) &&
entry.length <= 128 &&
entry.every((item) => typeof item === 'string' && item.length <= 128)
)
output[key] = entry as readonly string[];
else return undefined;
}
const serialized = JSON.stringify(output);
return Buffer.byteLength(serialized, 'utf8') > MAX_ACTION_BODY_BYTES ? undefined : serialized;
}
export class SafeUpstreamGateway {
constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {}
private assertOrigin(origin: string): string {
@@ -90,6 +313,28 @@ export class SafeUpstreamGateway {
return this.postZeroBody(origin, '/api/service/restart', cookie);
}
/**
* The baseband restart endpoint is a JSON handler that takes no fields, so it still expects a
* parseable `{}` document plus a content type; a zero-byte POST is rejected by the device.
*/
async postBasebandRestart(origin: string, cookie?: string): Promise<UpstreamResponse> {
const base = this.assertOrigin(origin);
try {
return await this.options.transport.post(
`${base}/api/baseband/restart`,
this.sessionHeaders(cookie, { 'content-type': 'application/json' }),
'{}',
);
} catch (error) {
if (
error instanceof UpstreamError &&
(error.code === 'UNSAFE_ORIGIN' || error.code === 'UNSAFE_RESOLUTION')
)
throw new OperationNotDispatchedError(error.code);
throw error;
}
}
async postSystemReboot(
origin: string,
delaySeconds: number,
@@ -123,16 +368,17 @@ export class SafeUpstreamGateway {
url.search.slice(1),
)
: null;
const readQuery = DEVICE_READ_PATHS[url.pathname];
const allowedRead = readQuery !== undefined && readQuery.test(url.search.slice(1));
if (
request.secret !== undefined ||
request.body !== undefined ||
request.sms !== undefined ||
request.smsBatchDelete !== undefined ||
request.deviceAction !== undefined ||
(headerKeys !== 'accept' && headerKeys !== 'accept,cookie') ||
(url.pathname !== '/api/stats' &&
url.pathname !== '/api/sim' &&
url.pathname !== '/api/health' &&
!smsQuery) ||
(!smsList && url.search) ||
(!smsQuery && !allowedRead) ||
(smsList && !smsQuery) ||
url.hash ||
url.username ||
url.password ||
@@ -149,6 +395,8 @@ export class SafeUpstreamGateway {
request.method !== 'POST' ||
request.secret !== undefined ||
request.body !== undefined ||
request.smsBatchDelete !== undefined ||
request.deviceAction !== undefined ||
!sms ||
(headerKeys !== 'accept,content-type' && headerKeys !== 'accept,content-type,cookie') ||
!/^\+?[0-9][0-9 ()-]{2,31}$/u.test(sms.phoneNumber) ||
@@ -168,8 +416,69 @@ export class SafeUpstreamGateway {
JSON.stringify({ phone_number: sms.phoneNumber, content: sms.content }),
);
}
if (url.pathname === '/api/sms/batch-delete') {
const ids = request.smsBatchDelete?.ids;
if (
request.method !== 'POST' ||
request.secret !== undefined ||
request.body !== undefined ||
request.sms !== undefined ||
request.deviceAction !== undefined ||
!Array.isArray(ids) ||
ids.length < 1 ||
ids.length > 500 ||
ids.some((id) => !Number.isSafeInteger(id) || id < 0) ||
(headerKeys !== 'accept,content-type' && headerKeys !== 'accept,content-type,cookie') ||
url.search ||
url.hash ||
url.username ||
url.password ||
(request.headers.cookie !== undefined &&
(typeof request.headers.cookie !== 'string' ||
!/^simadmin_session=[^;\s,]+$/.test(request.headers.cookie)))
)
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.post(request.url, request.headers, JSON.stringify({ ids }));
}
if (
request.deviceAction === undefined &&
(request.method === 'DELETE' ||
(request.method === 'POST' && url.pathname.startsWith('/api/notifications/queue')))
) {
const queuePath =
/^\/api\/notifications\/queue(?:$|\/(?:retry-all|clear|[A-Za-z0-9_.-]{1,128}(?:\/retry)?))$/u.exec(
url.pathname,
);
if (
request.secret !== undefined ||
request.body !== undefined ||
request.sms !== undefined ||
request.smsBatchDelete !== undefined ||
request.deviceAction !== undefined ||
!queuePath ||
url.search ||
url.hash ||
url.username ||
url.password ||
(headerKeys !== 'accept' && headerKeys !== 'accept,cookie') ||
(request.headers.cookie !== undefined &&
(typeof request.headers.cookie !== 'string' ||
!/^simadmin_session=[^;\s,]+$/.test(request.headers.cookie)))
)
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (request.method === 'DELETE') {
if (!new RegExp('^/api/notifications/queue/[A-Za-z0-9_.-]{1,128}$', 'u').test(url.pathname))
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (!this.options.transport.delete) throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.delete(request.url, request.headers);
}
return this.options.transport.post(request.url, request.headers, '');
}
if (request.deviceAction !== undefined) return this.deviceAction(request, url, headerKeys);
// Auth secrets may travel over HTTPS anywhere, or over HTTP only to literal private hosts.
// Public/cleartext auth is still rejected here; SSRF private-only dial remains in transport.
if (request.sms !== undefined || request.smsBatchDelete !== undefined)
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (!this.allowsAuthProtocol(url)) throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (request.url.endsWith('/api/auth/login')) {
@@ -189,6 +498,78 @@ export class SafeUpstreamGateway {
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
}
/**
* Device mutations are allowlisted by path and payload shape. The console never learns a path
* the transport has not already agreed to speak, so a compromised device module cannot turn the
* control plane into a generic proxy.
*/
private deviceAction(
request: UpstreamRequest,
url: URL,
headerKeys: string,
): Promise<UpstreamResponse> {
const pathname = url.pathname;
const payload = request.deviceAction?.body;
const cookie = request.headers.cookie;
if (
request.secret !== undefined ||
request.body !== undefined ||
request.sms !== undefined ||
request.smsBatchDelete !== undefined ||
url.hash ||
url.username ||
url.password ||
(url.search &&
!DEVICE_ACTION_QUERY_POSTS.some(
(entry) => entry.path.test(pathname) && entry.query.test(url.search.slice(1)),
)) ||
(cookie !== undefined &&
(typeof cookie !== 'string' || !/^simadmin_session=[^;\s,]+$/.test(cookie)))
)
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (request.method === 'DELETE') {
if (
!DEVICE_ACTION_PARAMETRIC_DELETES.some((pattern) => pattern.test(pathname)) ||
(headerKeys !== 'accept' && headerKeys !== 'accept,cookie') ||
!this.options.transport.delete
)
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.delete(request.url, request.headers);
}
if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
const shape = deviceActionShape(pathname);
if (shape === undefined) throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (shape === 'empty') {
if (payload !== undefined && !(isRecordLike(payload) && Object.keys(payload).length === 0))
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (headerKeys !== 'accept' && headerKeys !== 'accept,cookie')
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.post(
request.url,
request.headers,
payload === undefined ? '' : '{}',
);
}
if (shape === 'empty-json') {
if (payload !== undefined && !(isRecordLike(payload) && Object.keys(payload).length === 0))
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (headerKeys !== 'accept,content-type' && headerKeys !== 'accept,content-type,cookie')
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.post(request.url, request.headers, '{}');
}
if (payload === undefined) throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
const serialized = canonicalActionBody(payload);
if (serialized === undefined) throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (
headerKeys !== 'accept,content-type' &&
headerKeys !== 'accept,content-type,cookie' &&
headerKeys !== 'content-type' &&
headerKeys !== 'content-type,cookie'
)
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.post(request.url, request.headers, serialized);
}
private allowsAuthProtocol(url: URL): boolean {
if (url.protocol === 'https:') return true;
if (url.protocol !== 'http:') return false;