From 8018b36adf81294061f53add0817bf2f26f1affa Mon Sep 17 00:00:00 2001 From: chick Date: Fri, 17 Jul 2026 09:07:50 +0800 Subject: [PATCH] feat(operations): add audited runtime catalog --- apps/api/package.json | 1 + .../operations/operation-catalog-58e2204.json | 938 ++++++++++++++++++ .../operations/operation-catalog-data.ts | 7 + .../operation-catalog-service.test.ts | 64 ++ .../operations/operation-catalog-service.ts | 73 ++ apps/api/src/control-plane.operations.test.ts | 30 + apps/api/src/control-plane.ts | 8 +- .../interface/http/operation-routes.test.ts | 69 ++ .../src/interface/http/operation-routes.ts | 109 ++ .../src/catalog-metadata-58e2204.ts | 139 +++ packages/operation-registry/src/index.ts | 29 +- .../src/runtime-registry.ts | 65 +- .../test/catalog-metadata.test.ts | 64 ++ .../test/runtime-registry.test.ts | 39 +- pnpm-lock.yaml | 3 + 15 files changed, 1617 insertions(+), 21 deletions(-) create mode 100644 apps/api/src/application/operations/operation-catalog-58e2204.json create mode 100644 apps/api/src/application/operations/operation-catalog-data.ts create mode 100644 apps/api/src/application/operations/operation-catalog-service.test.ts create mode 100644 apps/api/src/application/operations/operation-catalog-service.ts create mode 100644 apps/api/src/control-plane.operations.test.ts create mode 100644 apps/api/src/interface/http/operation-routes.test.ts create mode 100644 apps/api/src/interface/http/operation-routes.ts create mode 100644 packages/operation-registry/src/catalog-metadata-58e2204.ts create mode 100644 packages/operation-registry/test/catalog-metadata.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index c5df477..162d008 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@multi-simadmin/contracts": "workspace:*", + "@multi-simadmin/operation-registry": "workspace:*", "better-sqlite3": "12.11.1", "drizzle-orm": "0.45.2", "fastify": "5.10.0" diff --git a/apps/api/src/application/operations/operation-catalog-58e2204.json b/apps/api/src/application/operations/operation-catalog-58e2204.json new file mode 100644 index 0000000..d23c2fd --- /dev/null +++ b/apps/api/src/application/operations/operation-catalog-58e2204.json @@ -0,0 +1,938 @@ +[ + { + "operationId": "deleteCallHistoryId", + "title": "Delete Call History ID", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.deleteCallHistoryId.parameters.v1" + }, + { + "operationId": "deleteEsimProfilesIccid", + "title": "Delete eSIM Profile ICCID", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.deleteEsimProfilesIccid.parameters.v1" + }, + { + "operationId": "deleteNotificationsQueueId", + "title": "Delete Notifications Queue ID", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.deleteNotificationsQueueId.parameters.v1" + }, + { + "operationId": "deleteSmsConversationPhoneNumber", + "title": "Delete SMS Conversation Phone Number", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.deleteSmsConversationPhoneNumber.parameters.v1" + }, + { + "operationId": "deleteSmsMessageId", + "title": "Delete SMS Message ID", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.deleteSmsMessageId.parameters.v1" + }, + { + "operationId": "getAirplaneMode", + "title": "Get Airplane Mode", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getAirplaneMode.parameters.v1" + }, + { + "operationId": "getApn", + "title": "Get APN", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getApn.parameters.v1" + }, + { + "operationId": "getAuthSettings", + "title": "Get Auth Settings", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getAuthSettings.parameters.v1" + }, + { + "operationId": "getAuthStatus", + "title": "Get Auth Status", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getAuthStatus.parameters.v1" + }, + { + "operationId": "getAutomationConfig", + "title": "Get Automation Config", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getAutomationConfig.parameters.v1" + }, + { + "operationId": "getAutomationLogs", + "title": "Get Automation Logs", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getAutomationLogs.parameters.v1" + }, + { + "operationId": "getBandLock", + "title": "Get Band Lock", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getBandLock.parameters.v1" + }, + { + "operationId": "getBasebandRestartStatus", + "title": "Get Baseband Restart Status", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getBasebandRestartStatus.parameters.v1" + }, + { + "operationId": "getCallForwarding", + "title": "Get Call Forwarding", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getCallForwarding.parameters.v1" + }, + { + "operationId": "getCallHistory", + "title": "Get Call History", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getCallHistory.parameters.v1" + }, + { + "operationId": "getCallSettings", + "title": "Get Call Settings", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getCallSettings.parameters.v1" + }, + { + "operationId": "getCallVolume", + "title": "Get Call Volume", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getCallVolume.parameters.v1" + }, + { + "operationId": "getCalls", + "title": "Get Calls", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getCalls.parameters.v1" + }, + { + "operationId": "getCellLock", + "title": "Get Cell Lock", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getCellLock.parameters.v1" + }, + { + "operationId": "getCells", + "title": "Get Cells", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getCells.parameters.v1" + }, + { + "operationId": "getConnectivity", + "title": "Get Connectivity", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getConnectivity.parameters.v1" + }, + { + "operationId": "getData", + "title": "Get Data", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getData.parameters.v1" + }, + { + "operationId": "getDevice", + "title": "Get Device", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getDevice.parameters.v1" + }, + { + "operationId": "getDeviceNetworkDdnsConfig", + "title": "Get Device Network DDNS Config", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getDeviceNetworkDdnsConfig.parameters.v1" + }, + { + "operationId": "getDeviceNetworkDdnsLogs", + "title": "Get Device Network DDNS Logs", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getDeviceNetworkDdnsLogs.parameters.v1" + }, + { + "operationId": "getDeviceNetworkDdnsStatus", + "title": "Get Device Network DDNS Status", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getDeviceNetworkDdnsStatus.parameters.v1" + }, + { + "operationId": "getDeviceNetworkWlanProfiles", + "title": "Get Device Network WLAN Profiles", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getDeviceNetworkWlanProfiles.parameters.v1" + }, + { + "operationId": "getDeviceNetworkWlanStatus", + "title": "Get Device Network WLAN Status", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getDeviceNetworkWlanStatus.parameters.v1" + }, + { + "operationId": "getEsimConfig", + "title": "Get eSIM Config", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getEsimConfig.parameters.v1" + }, + { + "operationId": "getEsimEuicc", + "title": "Get eSIM eUICC", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getEsimEuicc.parameters.v1" + }, + { + "operationId": "getEsimLpacStatus", + "title": "Get eSIM LPAC Status", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getEsimLpacStatus.parameters.v1" + }, + { + "operationId": "getEsimProfiles", + "title": "Get eSIM Profiles", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getEsimProfiles.parameters.v1" + }, + { + "operationId": "getHealth", + "title": "Get Health", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getHealth.parameters.v1" + }, + { + "operationId": "getImsStatus", + "title": "Get IMS Status", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getImsStatus.parameters.v1" + }, + { + "operationId": "getLocationCellInfo", + "title": "Get Location Cell Info", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getLocationCellInfo.parameters.v1" + }, + { + "operationId": "getNetwork", + "title": "Get Network", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getNetwork.parameters.v1" + }, + { + "operationId": "getNetworkConnectionAddresses", + "title": "Get Network Connection Addresses", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getNetworkConnectionAddresses.parameters.v1" + }, + { + "operationId": "getNetworkInterfaces", + "title": "Get Network Interfaces", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getNetworkInterfaces.parameters.v1" + }, + { + "operationId": "getNetworkOperators", + "title": "Get Network Operators", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getNetworkOperators.parameters.v1" + }, + { + "operationId": "getNetworkOperatorsScan", + "title": "Scan Network Operators", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getNetworkOperatorsScan.parameters.v1" + }, + { + "operationId": "getNetworkSignalStrength", + "title": "Get Network Signal Strength", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getNetworkSignalStrength.parameters.v1" + }, + { + "operationId": "getNotificationsConfig", + "title": "Get Notifications Config", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getNotificationsConfig.parameters.v1" + }, + { + "operationId": "getNotificationsLogs", + "title": "Get Notifications Logs", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getNotificationsLogs.parameters.v1" + }, + { + "operationId": "getNotificationsQueue", + "title": "Get Notifications Queue", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getNotificationsQueue.parameters.v1" + }, + { + "operationId": "getOtaStatus", + "title": "Get OTA Status", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getOtaStatus.parameters.v1" + }, + { + "operationId": "getRadioMode", + "title": "Get Radio Mode", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getRadioMode.parameters.v1" + }, + { + "operationId": "getRoaming", + "title": "Get Roaming", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getRoaming.parameters.v1" + }, + { + "operationId": "getSim", + "title": "Get SIM", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getSim.parameters.v1" + }, + { + "operationId": "getSmsConversation", + "title": "Get SMS Conversation", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getSmsConversation.parameters.v1" + }, + { + "operationId": "getSmsList", + "title": "Get SMS List", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getSmsList.parameters.v1" + }, + { + "operationId": "getSmsStats", + "title": "Get SMS Stats", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getSmsStats.parameters.v1" + }, + { + "operationId": "getStats", + "title": "Get Stats", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getStats.parameters.v1" + }, + { + "operationId": "getStatsCpu", + "title": "Get Stats CPU", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getStatsCpu.parameters.v1" + }, + { + "operationId": "getVoicemailStatus", + "title": "Get Voicemail Status", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getVoicemailStatus.parameters.v1" + }, + { + "operationId": "getWorkMode", + "title": "Get Work Mode", + "riskLevel": "R0", + "capability": "query", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.getWorkMode.parameters.v1" + }, + { + "operationId": "postAirplaneMode", + "title": "Set Airplane Mode", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postAirplaneMode.parameters.v1" + }, + { + "operationId": "postApn", + "title": "Set APN", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postApn.parameters.v1" + }, + { + "operationId": "postAuthLogin", + "title": "Log In", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postAuthLogin.parameters.v1" + }, + { + "operationId": "postAuthLogout", + "title": "Log Out", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postAuthLogout.parameters.v1" + }, + { + "operationId": "postAuthPassword", + "title": "Change Auth Password", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postAuthPassword.parameters.v1" + }, + { + "operationId": "postAuthSettings", + "title": "Set Auth Settings", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postAuthSettings.parameters.v1" + }, + { + "operationId": "postAuthSetup", + "title": "Set Up Auth", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postAuthSetup.parameters.v1" + }, + { + "operationId": "postAutomationConfig", + "title": "Set Automation Config", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postAutomationConfig.parameters.v1" + }, + { + "operationId": "postAutomationLogsClear", + "title": "Clear Automation Logs", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postAutomationLogsClear.parameters.v1" + }, + { + "operationId": "postAutomationTestTaskId", + "title": "Test Automation Task ID", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postAutomationTestTaskId.parameters.v1" + }, + { + "operationId": "postBandLock", + "title": "Set Band Lock", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postBandLock.parameters.v1" + }, + { + "operationId": "postBasebandRestart", + "title": "Restart Baseband", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postBasebandRestart.parameters.v1" + }, + { + "operationId": "postCallAnswer", + "title": "Answer Call", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postCallAnswer.parameters.v1" + }, + { + "operationId": "postCallDial", + "title": "Dial Call", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postCallDial.parameters.v1" + }, + { + "operationId": "postCallForwarding", + "title": "Set Call Forwarding", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postCallForwarding.parameters.v1" + }, + { + "operationId": "postCallHangup", + "title": "Hang Up Call", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postCallHangup.parameters.v1" + }, + { + "operationId": "postCallHangupAll", + "title": "Hang Up All Calls", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postCallHangupAll.parameters.v1" + }, + { + "operationId": "postCallHistoryClear", + "title": "Clear Call History", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postCallHistoryClear.parameters.v1" + }, + { + "operationId": "postCallSettings", + "title": "Set Call Settings", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postCallSettings.parameters.v1" + }, + { + "operationId": "postCallVolume", + "title": "Set Call Volume", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postCallVolume.parameters.v1" + }, + { + "operationId": "postCellLock", + "title": "Set Cell Lock", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postCellLock.parameters.v1" + }, + { + "operationId": "postCellLockUnlockAll", + "title": "Unlock All Cell Locks", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postCellLockUnlockAll.parameters.v1" + }, + { + "operationId": "postCellMonitorStart", + "title": "Start Cell Monitor", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postCellMonitorStart.parameters.v1" + }, + { + "operationId": "postCellMonitorStop", + "title": "Stop Cell Monitor", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postCellMonitorStop.parameters.v1" + }, + { + "operationId": "postData", + "title": "Set Data", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postData.parameters.v1" + }, + { + "operationId": "postDeviceNetworkDdnsConfig", + "title": "Set Device Network DDNS Config", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postDeviceNetworkDdnsConfig.parameters.v1" + }, + { + "operationId": "postDeviceNetworkDdnsLogsClear", + "title": "Clear Device Network DDNS Logs", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postDeviceNetworkDdnsLogsClear.parameters.v1" + }, + { + "operationId": "postDeviceNetworkDdnsSync", + "title": "Sync Device Network DDNS", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postDeviceNetworkDdnsSync.parameters.v1" + }, + { + "operationId": "postDeviceNetworkWlanConnect", + "title": "Connect Device Network WLAN", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postDeviceNetworkWlanConnect.parameters.v1" + }, + { + "operationId": "postDeviceNetworkWlanDisconnect", + "title": "Disconnect Device Network WLAN", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postDeviceNetworkWlanDisconnect.parameters.v1" + }, + { + "operationId": "postDeviceNetworkWlanEnabled", + "title": "Set Device Network WLAN Enabled", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postDeviceNetworkWlanEnabled.parameters.v1" + }, + { + "operationId": "postDeviceNetworkWlanForget", + "title": "Forget Device Network WLAN", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postDeviceNetworkWlanForget.parameters.v1" + }, + { + "operationId": "postDeviceNetworkWlanProfile", + "title": "Set Device Network WLAN Profile", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postDeviceNetworkWlanProfile.parameters.v1" + }, + { + "operationId": "postDeviceNetworkWlanScan", + "title": "Scan Device Network WLAN", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postDeviceNetworkWlanScan.parameters.v1" + }, + { + "operationId": "postEsimConfig", + "title": "Set eSIM Config", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postEsimConfig.parameters.v1" + }, + { + "operationId": "postEsimLpacRepair", + "title": "Repair eSIM LPAC", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postEsimLpacRepair.parameters.v1" + }, + { + "operationId": "postEsimProfiles", + "title": "Add eSIM Profile", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postEsimProfiles.parameters.v1" + }, + { + "operationId": "postEsimProfilesIccidEnable", + "title": "Enable eSIM Profile ICCID", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postEsimProfilesIccidEnable.parameters.v1" + }, + { + "operationId": "postEsimProfilesIccidRename", + "title": "Rename eSIM Profile ICCID", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postEsimProfilesIccidRename.parameters.v1" + }, + { + "operationId": "postNetworkRegisterAuto", + "title": "Register Network Automatically", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postNetworkRegisterAuto.parameters.v1" + }, + { + "operationId": "postNetworkRegisterManual", + "title": "Register Network Manually", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postNetworkRegisterManual.parameters.v1" + }, + { + "operationId": "postNotificationsConfig", + "title": "Set Notifications Config", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postNotificationsConfig.parameters.v1" + }, + { + "operationId": "postNotificationsLogsClear", + "title": "Clear Notifications Logs", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postNotificationsLogsClear.parameters.v1" + }, + { + "operationId": "postNotificationsQueueClear", + "title": "Clear Notifications Queue", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postNotificationsQueueClear.parameters.v1" + }, + { + "operationId": "postNotificationsQueueIdRetry", + "title": "Retry Notifications Queue ID", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postNotificationsQueueIdRetry.parameters.v1" + }, + { + "operationId": "postNotificationsQueueRetryAll", + "title": "Retry All Notifications Queue Items", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postNotificationsQueueRetryAll.parameters.v1" + }, + { + "operationId": "postNotificationsTestChannel", + "title": "Test Notifications Channel", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postNotificationsTestChannel.parameters.v1" + }, + { + "operationId": "postOtaApply", + "title": "Apply OTA", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postOtaApply.parameters.v1" + }, + { + "operationId": "postOtaCancel", + "title": "Cancel OTA", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postOtaCancel.parameters.v1" + }, + { + "operationId": "postOtaLatestRelease", + "title": "Get OTA Latest Release", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postOtaLatestRelease.parameters.v1" + }, + { + "operationId": "postOtaOnlinePrepare", + "title": "Prepare OTA Online", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postOtaOnlinePrepare.parameters.v1" + }, + { + "operationId": "postOtaUpload", + "title": "Upload OTA", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postOtaUpload.parameters.v1" + }, + { + "operationId": "postRadioMode", + "title": "Set Radio Mode", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postRadioMode.parameters.v1" + }, + { + "operationId": "postRoaming", + "title": "Set Roaming", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postRoaming.parameters.v1" + }, + { + "operationId": "postServiceRestart", + "title": "Restart Service", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postServiceRestart.parameters.v1" + }, + { + "operationId": "postSimCache", + "title": "Run SIM Cache", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postSimCache.parameters.v1" + }, + { + "operationId": "postSimDetailsRefresh", + "title": "Run SIM Details Refresh", + "riskLevel": "R1", + "capability": "command", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postSimDetailsRefresh.parameters.v1" + }, + { + "operationId": "postSmsBatchDelete", + "title": "Delete SMS Batch", + "riskLevel": "R3", + "capability": "job", + "batchable": true, + "parameterSchemaId": "simadmin.58e2204.postSmsBatchDelete.parameters.v1" + }, + { + "operationId": "postSmsClear", + "title": "Clear SMS", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postSmsClear.parameters.v1" + }, + { + "operationId": "postSmsSend", + "title": "Send SMS", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postSmsSend.parameters.v1" + }, + { + "operationId": "postSystemReboot", + "title": "Reboot System", + "riskLevel": "R3", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postSystemReboot.parameters.v1" + }, + { + "operationId": "postWorkMode", + "title": "Set Work Mode", + "riskLevel": "R2", + "capability": "job", + "batchable": false, + "parameterSchemaId": "simadmin.58e2204.postWorkMode.parameters.v1" + } +] diff --git a/apps/api/src/application/operations/operation-catalog-data.ts b/apps/api/src/application/operations/operation-catalog-data.ts new file mode 100644 index 0000000..a05ab2b --- /dev/null +++ b/apps/api/src/application/operations/operation-catalog-data.ts @@ -0,0 +1,7 @@ +import catalog from './operation-catalog-58e2204.json' with { type: 'json' }; +import type { OperationCatalogRegistry, OperationCatalogRow } from './operation-catalog-service.js'; + +/** Generated safe six-field projection of the audited 58e2204 registry. */ +export const operationCatalogRegistry: OperationCatalogRegistry = Object.freeze({ + listOperations: (): readonly OperationCatalogRow[] => catalog as readonly OperationCatalogRow[], +}); diff --git a/apps/api/src/application/operations/operation-catalog-service.test.ts b/apps/api/src/application/operations/operation-catalog-service.test.ts new file mode 100644 index 0000000..a341b6a --- /dev/null +++ b/apps/api/src/application/operations/operation-catalog-service.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { operationCatalogRegistry } from './operation-catalog-data.js'; +import { OperationCatalogService } from './operation-catalog-service.js'; + +const service = new OperationCatalogService(operationCatalogRegistry); + +describe('OperationCatalogService', () => { + it('returns a frozen contract-safe default page with six fields only', () => { + const page = service.list({}); + expect(page.page).toEqual({ page: 1, pageSize: 25, total: 117 }); + expect(page.items).toHaveLength(25); + expect(Object.keys(page.items[0]!)).toEqual([ + 'operationId', + 'title', + 'risk', + 'capability', + 'batchable', + 'parameterSchemaId', + ]); + expect(page.items.map(({ operationId }) => operationId)).toEqual( + [...page.items.map(({ operationId }) => operationId)].sort(), + ); + expect(Object.isFrozen(page)).toBe(true); + expect(Object.isFrozen(page.items)).toBe(true); + expect(page.items.every(Object.isFrozen)).toBe(true); + }); + + it('filters all supported fields and searches case-insensitively', () => { + const filtered = service.list({ + risk: 'R0', + capability: 'query', + batchable: false, + search: 'sms', + }); + expect(filtered.items.length).toBeGreaterThan(0); + expect( + filtered.items.every( + (item) => + item.risk === 'R0' && + item.capability === 'query' && + !item.batchable && + /sms/i.test(`${item.operationId} ${item.title}`), + ), + ).toBe(true); + }); + + it('keeps operationId ascending as the final tie breaker when descending', () => { + for (const sort of ['operationId', 'risk', 'capability'] as const) { + const page = service.list({ pageSize: 100, sort, direction: 'desc' }); + for (let index = 1; index < page.items.length; index += 1) { + const previous = page.items[index - 1]!; + const current = page.items[index]!; + if (previous[sort] === current[sort]) + expect(previous.operationId < current.operationId).toBe(true); + } + } + }); + + it('returns an empty beyond-last page without changing total', () => { + const page = service.list({ page: 999 }); + expect(page.items).toEqual([]); + expect(page.page.total).toBe(117); + }); +}); diff --git a/apps/api/src/application/operations/operation-catalog-service.ts b/apps/api/src/application/operations/operation-catalog-service.ts new file mode 100644 index 0000000..64f4256 --- /dev/null +++ b/apps/api/src/application/operations/operation-catalog-service.ts @@ -0,0 +1,73 @@ +import type { + OperationCatalogEntry, + OperationPage, + OperationPageQuery, +} from '@multi-simadmin/contracts'; + +export interface OperationCatalogRow { + readonly operationId: string; + readonly title: string; + readonly riskLevel: OperationCatalogEntry['risk']; + readonly capability: string; + readonly batchable: boolean; + readonly parameterSchemaId: string; +} + +export interface OperationCatalogRegistry { + listOperations(): readonly OperationCatalogRow[]; +} + +const freeze = (value: T): T => Object.freeze(value); +const compare = (left: string, right: string): number => (left < right ? -1 : left > right ? 1 : 0); + +/** Pure safe projection over the audited runtime registry. */ +export class OperationCatalogService { + constructor(private readonly registry: OperationCatalogRegistry) {} + + list(query: OperationPageQuery): OperationPage { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 25; + const search = query.search?.toLowerCase(); + const rows: OperationCatalogEntry[] = []; + + for (const row of this.registry.listOperations()) { + if (query.risk !== undefined && row.riskLevel !== query.risk) continue; + if (query.capability !== undefined && row.capability !== query.capability) continue; + if (query.batchable !== undefined && row.batchable !== query.batchable) continue; + if ( + search !== undefined && + !row.operationId.toLowerCase().includes(search) && + !row.title.toLowerCase().includes(search) + ) { + continue; + } + rows.push( + freeze({ + operationId: row.operationId, + title: row.title, + risk: row.riskLevel, + capability: row.capability, + batchable: row.batchable, + parameterSchemaId: row.parameterSchemaId, + }), + ); + } + + const sort = query.sort ?? 'operationId'; + const direction = query.direction === 'desc' ? -1 : 1; + const value = (row: OperationCatalogEntry): string => { + if (sort === 'risk') return row.risk; + if (sort === 'capability') return row.capability; + return row.operationId; + }; + rows.sort((left, right) => { + const byField = compare(value(left), value(right)); + return byField === 0 ? compare(left.operationId, right.operationId) : direction * byField; + }); + const offset = (page - 1) * pageSize; + return freeze({ + items: freeze(rows.slice(offset, offset + pageSize)), + page: freeze({ page, pageSize, total: rows.length }), + }); + } +} diff --git a/apps/api/src/control-plane.operations.test.ts b/apps/api/src/control-plane.operations.test.ts new file mode 100644 index 0000000..7316f3e --- /dev/null +++ b/apps/api/src/control-plane.operations.test.ts @@ -0,0 +1,30 @@ +import Database from 'better-sqlite3'; +import { expect, it } from 'vitest'; +import { buildControlPlaneApp } from './control-plane.js'; +import { migrateDatabase } from './infrastructure/database/migrations.js'; + +it('registers the read-only operations catalog without touching upstream', async () => { + const db = new Database(':memory:'); + migrateDatabase(db); + let upstreamCalls = 0; + const app = buildControlPlaneApp({ + db, + store: { set: async () => '', get: async () => undefined, delete: async () => false }, + upstream: { + get: async () => { + upstreamCalls += 1; + throw new Error('unexpected'); + }, + request: async () => { + upstreamCalls += 1; + throw new Error('unexpected'); + }, + }, + }); + const response = await app.inject('/api/v1/operations?pageSize=1'); + expect(response.statusCode).toBe(200); + expect(response.json().page.total).toBe(117); + expect(upstreamCalls).toBe(0); + await app.close(); + db.close(); +}); diff --git a/apps/api/src/control-plane.ts b/apps/api/src/control-plane.ts index c6a39fc..bb49617 100644 --- a/apps/api/src/control-plane.ts +++ b/apps/api/src/control-plane.ts @@ -1,4 +1,6 @@ import type Database from 'better-sqlite3'; +import { registerOperationRoutes } from './interface/http/operation-routes.js'; +import { operationCatalogRegistry } from './application/operations/operation-catalog-data.js'; import type { FastifyInstance } from 'fastify'; import { buildApp, type BuildAppOptions } from './app.js'; import { @@ -54,8 +56,10 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane deletion.reconcileInterruptedJobs(); const app = buildApp({ ...options.app, - registerRoutes: (app) => - registerInstanceRoutes(app, { instances, connections, login, deletion }), + registerRoutes: (app) => { + registerInstanceRoutes(app, { instances, connections, login, deletion }); + registerOperationRoutes(app, operationCatalogRegistry); + }, }); Object.assign(app, { retryPendingSecretCleanup: () => instances.retryPendingSecretCleanup(), diff --git a/apps/api/src/interface/http/operation-routes.test.ts b/apps/api/src/interface/http/operation-routes.test.ts new file mode 100644 index 0000000..b224f2f --- /dev/null +++ b/apps/api/src/interface/http/operation-routes.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { buildApp } from '../../app.js'; +import { operationCatalogRegistry } from '../../application/operations/operation-catalog-data.js'; +import { registerOperationRoutes } from './operation-routes.js'; + +const apps: ReturnType[] = []; +afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close()))); +const app = () => { + const instance = buildApp({ + registerRoutes: (server) => registerOperationRoutes(server, operationCatalogRegistry), + }); + apps.push(instance); + return instance; +}; + +describe('GET /api/v1/operations', () => { + it('returns only the safe catalog projection', async () => { + const response = await app().inject('/api/v1/operations?pageSize=1'); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ page: { page: 1, pageSize: 1, total: 117 } }); + expect(Object.keys(response.json().items[0])).toEqual([ + 'operationId', + 'title', + 'risk', + 'capability', + 'batchable', + 'parameterSchemaId', + ]); + for (const forbidden of [ + 'method', + 'pathTemplate', + 'sourceEvidence', + 'sensitiveFields', + 'handler', + ]) + expect(response.body).not.toContain(`\"${forbidden}\"`); + }); + + it.each([ + 'unknown=x', + 'page=1&page=2', + 'page[]=1', + 'page=0', + 'page=01', + 'page=1.5', + 'pageSize=101', + 'risk=R4', + 'capability=bogus', + 'batchable=True', + 'batchable=1', + 'sort=title', + 'direction=down', + `search=${'x'.repeat(201)}`, + ])('rejects invalid strict scalar query: %s', async (query) => { + const response = await app().inject(`/api/v1/operations?${query}`); + expect(response.statusCode).toBe(400); + expect(response.headers['content-type']).toContain('application/problem+json'); + expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED', status: 400 }); + }); + + it('accepts every frozen capability including job', async () => { + const response = await app().inject('/api/v1/operations?capability=job&pageSize=100'); + expect(response.statusCode).toBe(200); + expect(response.json().items.length).toBeGreaterThan(0); + expect( + response.json().items.every((item: { capability: string }) => item.capability === 'job'), + ).toBe(true); + }); +}); diff --git a/apps/api/src/interface/http/operation-routes.ts b/apps/api/src/interface/http/operation-routes.ts new file mode 100644 index 0000000..8ba5505 --- /dev/null +++ b/apps/api/src/interface/http/operation-routes.ts @@ -0,0 +1,109 @@ +import type { OperationPageQuery } from '@multi-simadmin/contracts'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { OperationCatalogService } from '../../application/operations/operation-catalog-service.js'; +import type { OperationCatalogRegistry } from '../../application/operations/operation-catalog-service.js'; + +const capabilities = new Set(['query', 'command', 'job']); +const keys = new Set([ + 'page', + 'pageSize', + 'sort', + 'direction', + 'risk', + 'capability', + 'batchable', + 'search', +]); +class CatalogValidationError extends Error {} + +function scalarQuery(request: FastifyRequest): Record { + const raw = request.query; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) + throw new CatalogValidationError(); + const result: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (!keys.has(key) || typeof value !== 'string') throw new CatalogValidationError(); + result[key] = value; + } + return result; +} + +function parseQuery(request: FastifyRequest): OperationPageQuery { + const query = scalarQuery(request); + const result: { + page?: number; + pageSize?: number; + sort?: 'operationId' | 'risk' | 'capability'; + direction?: 'asc' | 'desc'; + risk?: 'R0' | 'R1' | 'R2' | 'R3'; + capability?: string; + batchable?: boolean; + search?: string; + } = {}; + const integer = (key: 'page' | 'pageSize', maximum?: number): void => { + const value = query[key]; + if (value === undefined) return; + if (!/^[1-9]\d*$/.test(value)) throw new CatalogValidationError(); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || (maximum !== undefined && parsed > maximum)) + throw new CatalogValidationError(); + result[key] = parsed; + }; + integer('page'); + integer('pageSize', 100); + const sort = query.sort; + if (sort !== undefined) { + if (sort !== 'operationId' && sort !== 'risk' && sort !== 'capability') + throw new CatalogValidationError(); + result.sort = sort; + } + const direction = query.direction; + if (direction !== undefined) { + if (direction !== 'asc' && direction !== 'desc') throw new CatalogValidationError(); + result.direction = direction; + } + const risk = query.risk; + if (risk !== undefined) { + if (risk !== 'R0' && risk !== 'R1' && risk !== 'R2' && risk !== 'R3') + throw new CatalogValidationError(); + result.risk = risk; + } + const capability = query.capability; + if (capability !== undefined) { + if (!capabilities.has(capability)) throw new CatalogValidationError(); + result.capability = capability; + } + const batchable = query.batchable; + if (batchable !== undefined) { + if (batchable !== 'true' && batchable !== 'false') throw new CatalogValidationError(); + result.batchable = batchable === 'true'; + } + const search = query.search; + if (search !== undefined) { + if (search.length > 200) throw new CatalogValidationError(); + result.search = search; + } + return result; +} + +export function registerOperationRoutes( + app: FastifyInstance, + registry: OperationCatalogRegistry, +): void { + const service = new OperationCatalogService(registry); + app.get('/api/v1/operations', async (request, reply) => { + try { + return service.list(parseQuery(request)); + } catch (error) { + if (!(error instanceof CatalogValidationError)) throw error; + return reply.code(400).type('application/problem+json').send({ + type: 'about:blank', + title: 'Bad Request', + status: 400, + code: 'VALIDATION_FAILED', + detail: 'The request did not satisfy the endpoint schema.', + requestId: request.id, + }); + } + }); +} diff --git a/packages/operation-registry/src/catalog-metadata-58e2204.ts b/packages/operation-registry/src/catalog-metadata-58e2204.ts new file mode 100644 index 0000000..ad6ebe1 --- /dev/null +++ b/packages/operation-registry/src/catalog-metadata-58e2204.ts @@ -0,0 +1,139 @@ +export interface OperationCatalogMetadata { + readonly title: string; + readonly parameterSchemaId: string; +} + +/** Explicit reviewed titles keyed by every operation in the audited 58e2204 registry. */ +const operationTitles58e2204: Readonly> = Object.freeze({ + getDevice: 'Get Device', + getSim: 'Get SIM', + postSimDetailsRefresh: 'Run SIM Details Refresh', + postSimCache: 'Run SIM Cache', + getNetwork: 'Get Network', + getCells: 'Get Cells', + postCellMonitorStart: 'Start Cell Monitor', + postCellMonitorStop: 'Stop Cell Monitor', + getRadioMode: 'Get Radio Mode', + postRadioMode: 'Set Radio Mode', + getBandLock: 'Get Band Lock', + postBandLock: 'Set Band Lock', + getNetworkInterfaces: 'Get Network Interfaces', + getNetworkConnectionAddresses: 'Get Network Connection Addresses', + getDeviceNetworkDdnsConfig: 'Get Device Network DDNS Config', + postDeviceNetworkDdnsConfig: 'Set Device Network DDNS Config', + getDeviceNetworkDdnsStatus: 'Get Device Network DDNS Status', + postDeviceNetworkDdnsSync: 'Sync Device Network DDNS', + getDeviceNetworkDdnsLogs: 'Get Device Network DDNS Logs', + postDeviceNetworkDdnsLogsClear: 'Clear Device Network DDNS Logs', + getDeviceNetworkWlanStatus: 'Get Device Network WLAN Status', + postDeviceNetworkWlanEnabled: 'Set Device Network WLAN Enabled', + postDeviceNetworkWlanScan: 'Scan Device Network WLAN', + getDeviceNetworkWlanProfiles: 'Get Device Network WLAN Profiles', + postDeviceNetworkWlanForget: 'Forget Device Network WLAN', + postDeviceNetworkWlanConnect: 'Connect Device Network WLAN', + postDeviceNetworkWlanDisconnect: 'Disconnect Device Network WLAN', + postDeviceNetworkWlanProfile: 'Set Device Network WLAN Profile', + getNetworkSignalStrength: 'Get Network Signal Strength', + getLocationCellInfo: 'Get Location Cell Info', + getNetworkOperators: 'Get Network Operators', + getNetworkOperatorsScan: 'Scan Network Operators', + postNetworkRegisterManual: 'Register Network Manually', + postNetworkRegisterAuto: 'Register Network Automatically', + getApn: 'Get APN', + postApn: 'Set APN', + getCellLock: 'Get Cell Lock', + postCellLock: 'Set Cell Lock', + postCellLockUnlockAll: 'Unlock All Cell Locks', + getData: 'Get Data', + postData: 'Set Data', + getRoaming: 'Get Roaming', + postRoaming: 'Set Roaming', + getAirplaneMode: 'Get Airplane Mode', + postAirplaneMode: 'Set Airplane Mode', + postBasebandRestart: 'Restart Baseband', + getBasebandRestartStatus: 'Get Baseband Restart Status', + getWorkMode: 'Get Work Mode', + postWorkMode: 'Set Work Mode', + getEsimConfig: 'Get eSIM Config', + postEsimConfig: 'Set eSIM Config', + getEsimLpacStatus: 'Get eSIM LPAC Status', + postEsimLpacRepair: 'Repair eSIM LPAC', + getEsimEuicc: 'Get eSIM eUICC', + getEsimProfiles: 'Get eSIM Profiles', + postEsimProfiles: 'Add eSIM Profile', + postEsimProfilesIccidEnable: 'Enable eSIM Profile ICCID', + postEsimProfilesIccidRename: 'Rename eSIM Profile ICCID', + deleteEsimProfilesIccid: 'Delete eSIM Profile ICCID', + getCalls: 'Get Calls', + postCallDial: 'Dial Call', + postCallHangup: 'Hang Up Call', + postCallHangupAll: 'Hang Up All Calls', + postCallAnswer: 'Answer Call', + getCallVolume: 'Get Call Volume', + postCallVolume: 'Set Call Volume', + getCallForwarding: 'Get Call Forwarding', + postCallForwarding: 'Set Call Forwarding', + getCallSettings: 'Get Call Settings', + postCallSettings: 'Set Call Settings', + getCallHistory: 'Get Call History', + deleteCallHistoryId: 'Delete Call History ID', + postCallHistoryClear: 'Clear Call History', + getImsStatus: 'Get IMS Status', + getVoicemailStatus: 'Get Voicemail Status', + postSmsSend: 'Send SMS', + getSmsList: 'Get SMS List', + getSmsConversation: 'Get SMS Conversation', + getSmsStats: 'Get SMS Stats', + postSmsBatchDelete: 'Delete SMS Batch', + deleteSmsConversationPhoneNumber: 'Delete SMS Conversation Phone Number', + deleteSmsMessageId: 'Delete SMS Message ID', + postSmsClear: 'Clear SMS', + getStats: 'Get Stats', + getStatsCpu: 'Get Stats CPU', + getConnectivity: 'Get Connectivity', + postSystemReboot: 'Reboot System', + postServiceRestart: 'Restart Service', + getNotificationsConfig: 'Get Notifications Config', + postNotificationsConfig: 'Set Notifications Config', + postNotificationsTestChannel: 'Test Notifications Channel', + getNotificationsLogs: 'Get Notifications Logs', + postNotificationsLogsClear: 'Clear Notifications Logs', + getNotificationsQueue: 'Get Notifications Queue', + postNotificationsQueueRetryAll: 'Retry All Notifications Queue Items', + postNotificationsQueueClear: 'Clear Notifications Queue', + deleteNotificationsQueueId: 'Delete Notifications Queue ID', + postNotificationsQueueIdRetry: 'Retry Notifications Queue ID', + getAutomationConfig: 'Get Automation Config', + postAutomationConfig: 'Set Automation Config', + getAutomationLogs: 'Get Automation Logs', + postAutomationLogsClear: 'Clear Automation Logs', + postAutomationTestTaskId: 'Test Automation Task ID', + getOtaStatus: 'Get OTA Status', + postOtaUpload: 'Upload OTA', + postOtaLatestRelease: 'Get OTA Latest Release', + postOtaOnlinePrepare: 'Prepare OTA Online', + postOtaApply: 'Apply OTA', + postOtaCancel: 'Cancel OTA', + postAuthPassword: 'Change Auth Password', + getAuthSettings: 'Get Auth Settings', + postAuthSettings: 'Set Auth Settings', + getHealth: 'Get Health', + getAuthStatus: 'Get Auth Status', + postAuthSetup: 'Set Up Auth', + postAuthLogin: 'Log In', + postAuthLogout: 'Log Out', +}); + +/** Stable schema IDs are materialized once from the explicit, exact reviewed key set. */ +export const operationMetadata58e2204: Readonly> = + Object.freeze( + Object.fromEntries( + Object.entries(operationTitles58e2204).map(([operationId, title]) => [ + operationId, + Object.freeze({ + title, + parameterSchemaId: `simadmin.58e2204.${operationId}.parameters.v1`, + }), + ]), + ), + ); diff --git a/packages/operation-registry/src/index.ts b/packages/operation-registry/src/index.ts index 5cc2e35..ffee8ca 100644 --- a/packages/operation-registry/src/index.ts +++ b/packages/operation-registry/src/index.ts @@ -1,3 +1,5 @@ +import { listOperations as listRuntimeOperations } from './runtime-registry.ts'; + export { OperationRegistry, RuntimeRegistryError, @@ -6,4 +8,29 @@ export { requireExecutableOperation, requireOperation, } from './runtime-registry.ts'; -export type { RuntimeOperationDescriptor, RuntimeRegistryErrorCode } from './runtime-registry.ts'; +export type { RuntimeRegistryErrorCode } from './runtime-registry.ts'; +export { operationMetadata58e2204 } from './catalog-metadata-58e2204.ts'; +export type { OperationCatalogMetadata } from './catalog-metadata-58e2204.ts'; + +/** Small public catalog row shape avoids exporting the 117-member internal descriptor union. */ +export interface PublicOperationCatalogRow { + readonly operationId: string; + readonly title: string; + readonly riskLevel: 'R0' | 'R1' | 'R2' | 'R3'; + readonly capability: string; + readonly batchable: boolean; + readonly parameterSchemaId: string; +} + +export const listPublicOperationCatalog = (): readonly PublicOperationCatalogRow[] => + listRuntimeOperations().map( + ({ operationId, title, riskLevel, capability, batchable, parameterSchemaId }) => + Object.freeze({ + operationId, + title, + riskLevel, + capability, + batchable, + parameterSchemaId, + }), + ); diff --git a/packages/operation-registry/src/runtime-registry.ts b/packages/operation-registry/src/runtime-registry.ts index dfdbfec..d269e37 100644 --- a/packages/operation-registry/src/runtime-registry.ts +++ b/packages/operation-registry/src/runtime-registry.ts @@ -1,4 +1,8 @@ import { operationAcceptanceOverrides58e2204 } from './acceptance-58e2204.ts'; +import { + operationMetadata58e2204, + type OperationCatalogMetadata, +} from './catalog-metadata-58e2204.ts'; import { upstream58e2204Operations } from './upstream-58e2204.ts'; export type RuntimeRegistryErrorCode = 'UNKNOWN_OPERATION' | 'DEDICATED_FLOW_REQUIRED'; @@ -13,18 +17,28 @@ export class RuntimeRegistryError extends Error { } } -type UpstreamOperation = (typeof upstream58e2204Operations)[number]; -type AcceptanceOverride = Record & { uiStrategy?: unknown }; +export interface RuntimeOperationDescriptor { + readonly operationId: string; + readonly method: string; + readonly pathTemplate: string; + readonly handler: string; + readonly capability: string; + readonly riskLevel: 'R0' | 'R1' | 'R2' | 'R3'; + readonly batchable: boolean; + readonly executionPolicy: string; + readonly upstreamDomain: string; + readonly module: string; + readonly uiStrategy: string; + readonly title: string; + readonly parameterSchemaId: string; + readonly [field: string]: unknown; +} -export type RuntimeOperationDescriptor = Readonly< - UpstreamOperation & { - module: UpstreamOperation['upstreamDomain']; - uiStrategy: string; - } ->; +type AcceptanceOverride = Readonly> & { readonly uiStrategy?: unknown }; const OPERATION_ID_PATTERN = /^[A-Za-z][A-Za-z0-9]*$/; const EXPECTED_OPERATION_COUNT = 117; +const PARAMETER_SCHEMA_ID_PATTERN = /^[A-Za-z0-9]+(?:[.-][A-Za-z0-9]+)+$/; function deepFreeze(value: T): T { if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value; @@ -43,13 +57,16 @@ export class OperationRegistry { readonly #operations: readonly RuntimeOperationDescriptor[]; constructor( - operations: readonly UpstreamOperation[], + operations: readonly Readonly>[], acceptanceOverrides: Readonly>, + metadata: Readonly>, ) { - const sourceById = new Map(); + const sourceById = new Map>>(); for (const operation of operations) { const id = operation.operationId; - if (!OPERATION_ID_PATTERN.test(id)) throw new Error(`invalid operationId in registry: ${id}`); + if (typeof id !== 'string' || !OPERATION_ID_PATTERN.test(id)) { + throw new Error(`invalid operationId in registry: ${String(id)}`); + } if (sourceById.has(id)) throw new Error(`duplicate operationId: ${id}`); sourceById.set(id, operation); } @@ -58,6 +75,9 @@ export class OperationRegistry { `runtime registry must contain exactly ${EXPECTED_OPERATION_COUNT} operations`, ); } + for (const id of Object.keys(metadata)) { + if (!sourceById.has(id)) throw new Error(`extra operation metadata: ${id}`); + } const descriptors: RuntimeOperationDescriptor[] = []; for (const id of [...sourceById.keys()].sort()) { @@ -69,10 +89,23 @@ export class OperationRegistry { throw new Error(`missing acceptance override: ${id}`); } const operation = sourceById.get(id)!; + if (!Object.hasOwn(metadata, id)) throw new Error(`missing operation metadata: ${id}`); + const catalog = metadata[id]!; + if (typeof catalog.title !== 'string' || catalog.title.trim().length === 0) { + throw new Error(`blank operation title: ${id}`); + } + if (!PARAMETER_SCHEMA_ID_PATTERN.test(catalog.parameterSchemaId)) { + throw new Error(`invalid parameter schema id: ${id}`); + } + if (typeof operation.upstreamDomain !== 'string') { + throw new Error(`invalid upstream domain: ${id}`); + } const descriptor = structuredClone({ ...operation, module: operation.upstreamDomain, uiStrategy: acceptance.uiStrategy, + title: catalog.title, + parameterSchemaId: catalog.parameterSchemaId, }) as RuntimeOperationDescriptor; descriptors.push(deepFreeze(descriptor)); } @@ -98,20 +131,20 @@ export class OperationRegistry { } requireExecutableOperation(operationId: string): RuntimeOperationDescriptor { - const operation = this.requireOperation(operationId); - if (operation.executionPolicy === 'dedicatedFlow') { + const descriptor = this.requireOperation(operationId); + if (descriptor.executionPolicy !== 'registeredOperation') { throw new RuntimeRegistryError( 'DEDICATED_FLOW_REQUIRED', - `Operation requires a dedicated flow: ${operationId}`, + `Operation is not executable through the registered-operation boundary: ${operationId}`, ); } - return operation; + return descriptor; } } - export const operationRegistry = new OperationRegistry( upstream58e2204Operations, operationAcceptanceOverrides58e2204, + operationMetadata58e2204, ); export const listOperations = (): readonly RuntimeOperationDescriptor[] => diff --git a/packages/operation-registry/test/catalog-metadata.test.ts b/packages/operation-registry/test/catalog-metadata.test.ts new file mode 100644 index 0000000..753e539 --- /dev/null +++ b/packages/operation-registry/test/catalog-metadata.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { OperationRegistry, operationMetadata58e2204, requireOperation } from '../src/index.ts'; +import { operationAcceptanceOverrides58e2204 } from '../src/acceptance-58e2204.ts'; +import { upstream58e2204Operations } from '../src/upstream-58e2204.ts'; + +test('explicit reviewed metadata covers every operation and preserves domain acronyms', () => { + assert.equal(Object.keys(operationMetadata58e2204).length, 117); + assert.deepEqual( + Object.keys(operationMetadata58e2204).toSorted(), + upstream58e2204Operations.map(({ operationId }) => operationId).toSorted(), + ); + for (const [operationId, metadata] of Object.entries(operationMetadata58e2204)) { + assert.match(metadata.title, /\S/); + assert.equal(metadata.parameterSchemaId, `simadmin.58e2204.${operationId}.parameters.v1`); + } + const titles = Object.values(operationMetadata58e2204) + .map(({ title }) => title) + .join(' '); + for (const acronym of ['SMS', 'APN', 'DDNS', 'WLAN', 'IMS', 'OTA', 'LPAC', 'eUICC', 'ICCID']) { + assert.match(titles, new RegExp(`\\b${acronym}\\b`), acronym); + } +}); + +test('constructor rejects missing/extra/blank/malformed metadata and freezes metadata fields', () => { + const rows = upstream58e2204Operations; + const missing = { ...operationMetadata58e2204 } as Record; + delete missing.getDevice; + assert.throws( + () => new OperationRegistry(rows, operationAcceptanceOverrides58e2204, missing as never), + /missing operation metadata: getDevice/, + ); + const extra = { + ...operationMetadata58e2204, + inventedOperation: { + title: 'Invented', + parameterSchemaId: 'simadmin.58e2204.inventedOperation.parameters.v1', + }, + }; + assert.throws( + () => new OperationRegistry(rows, operationAcceptanceOverrides58e2204, extra), + /extra operation metadata: inventedOperation/, + ); + const blank = { + ...operationMetadata58e2204, + getDevice: { ...operationMetadata58e2204.getDevice, title: ' ' }, + }; + assert.throws( + () => new OperationRegistry(rows, operationAcceptanceOverrides58e2204, blank), + /blank operation title: getDevice/, + ); + const malformed = { + ...operationMetadata58e2204, + getDevice: { ...operationMetadata58e2204.getDevice, parameterSchemaId: '../schema' }, + }; + assert.throws( + () => new OperationRegistry(rows, operationAcceptanceOverrides58e2204, malformed), + /invalid parameter schema id: getDevice/, + ); + const descriptor = requireOperation('getDevice'); + assert.equal(descriptor.title, 'Get Device'); + assert.equal(descriptor.parameterSchemaId, 'simadmin.58e2204.getDevice.parameters.v1'); + assert.ok(Object.isFrozen(descriptor)); +}); diff --git a/packages/operation-registry/test/runtime-registry.test.ts b/packages/operation-registry/test/runtime-registry.test.ts index 2f19463..7d9cf6e 100644 --- a/packages/operation-registry/test/runtime-registry.test.ts +++ b/packages/operation-registry/test/runtime-registry.test.ts @@ -10,7 +10,25 @@ import { } from '../src/index.ts'; import { upstream58e2204Operations } from '../src/upstream-58e2204.ts'; import { operationAcceptanceOverrides58e2204 } from '../src/acceptance-58e2204.ts'; +import { operationMetadata58e2204 } from '../src/catalog-metadata-58e2204.ts'; +test('generated API catalog is an exact safe projection of the runtime registry', async () => { + const { default: generated } = await import( + '../../../apps/api/src/application/operations/operation-catalog-58e2204.json', + { with: { type: 'json' } } + ); + const expected = listOperations().map( + ({ operationId, title, riskLevel, capability, batchable, parameterSchemaId }) => ({ + operationId, + title, + riskLevel, + capability, + batchable, + parameterSchemaId, + }), + ); + assert.deepEqual(generated, expected); +}); test('runtime registry projects exactly 117 descriptors in stable operationId order', () => { const listed = listOperations(); assert.equal(listed.length, 117); @@ -34,13 +52,18 @@ test('runtime registry projects exactly 117 descriptors in stable operationId or test('runtime construction rejects duplicate rows and missing acceptance overrides', () => { const rows = upstream58e2204Operations as readonly (typeof upstream58e2204Operations)[number][]; assert.throws( - () => new OperationRegistry([...rows, rows[0]], operationAcceptanceOverrides58e2204), + () => + new OperationRegistry( + [...rows, rows[0]], + operationAcceptanceOverrides58e2204, + operationMetadata58e2204, + ), /duplicate operationId: getDevice/, ); const missing = { ...operationAcceptanceOverrides58e2204 }; delete (missing as Record).getDevice; assert.throws( - () => new OperationRegistry(rows, missing), + () => new OperationRegistry(rows, missing, operationMetadata58e2204), /missing acceptance override: getDevice/, ); }); @@ -86,6 +109,18 @@ test('dedicated auth flows are catalogued but cannot cross executable boundary', id, ); } + const malformed = new OperationRegistry( + upstream58e2204Operations.map((row) => + row.operationId === 'getDevice' ? { ...row, executionPolicy: 'futurePolicy' } : row, + ), + operationAcceptanceOverrides58e2204, + operationMetadata58e2204, + ); + assert.throws( + () => malformed.requireExecutableOperation('getDevice'), + (error: unknown) => + error instanceof RuntimeRegistryError && error.code === 'DEDICATED_FLOW_REQUIRED', + ); assert.strictEqual(requireExecutableOperation('getDevice'), requireOperation('getDevice')); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6624f69..2412d0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@multi-simadmin/contracts': specifier: workspace:* version: link:../../packages/contracts + '@multi-simadmin/operation-registry': + specifier: workspace:* + version: link:../../packages/operation-registry better-sqlite3: specifier: 12.11.1 version: 12.11.1