@soat/cli 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +269 -29
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
/** Powered by @ttoss/config. https://ttoss.dev/docs/modules/packages/config/ */
|
|
2
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
2
|
import { createServer } from "node:http";
|
|
4
3
|
import * as path from "node:path";
|
|
5
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -11,13 +10,20 @@ import { program } from "commander";
|
|
|
11
10
|
import * as fs from "node:fs";
|
|
12
11
|
import { load } from "js-yaml";
|
|
13
12
|
import * as os from "node:os";
|
|
13
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
14
14
|
|
|
15
15
|
//#region package.json
|
|
16
|
-
var version = "0.
|
|
16
|
+
var version = "0.24.0";
|
|
17
17
|
|
|
18
18
|
//#endregion
|
|
19
19
|
//#region src/cli-wrappers/wrappers/formations.ts
|
|
20
20
|
var FORMATION_COMMANDS = ["validate-formation", "plan-formation", "create-formation", "update-formation"];
|
|
21
|
+
/**
|
|
22
|
+
* The two commands that *deploy*. `validate-formation` and `plan-formation`
|
|
23
|
+
* report on a template without touching a resource, so their outcome is the
|
|
24
|
+
* payload, not an exit code.
|
|
25
|
+
*/
|
|
26
|
+
var DEPLOY_COMMANDS = ["create-formation", "update-formation"];
|
|
21
27
|
var TEMPLATE_PATH_FLAG = "template-path";
|
|
22
28
|
var TEMPLATE_FILE_FLAG = "template-file";
|
|
23
29
|
var ENV_FILE_FLAG = "env-file";
|
|
@@ -121,9 +127,45 @@ var resolveParameterPair = args => {
|
|
|
121
127
|
})
|
|
122
128
|
};
|
|
123
129
|
};
|
|
130
|
+
var isRecord = value => {
|
|
131
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
132
|
+
};
|
|
133
|
+
var readString = value => {
|
|
134
|
+
return typeof value === "string" && value ? value : void 0;
|
|
135
|
+
};
|
|
136
|
+
/** The reason line, from the response's `error` bag when it carries one. */
|
|
137
|
+
var describeDeployReason = args => {
|
|
138
|
+
const reason = readString(args.error?.message);
|
|
139
|
+
if (!reason) return `No reason was reported on the response; run \`soat list-formation-events --formation-id ${args.formationId ?? "<id>"}\` for the operation history.`;
|
|
140
|
+
return `${readString(args.error?.code) ?? "UNKNOWN"}: ${reason}`;
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* A deploy whose reconciliation failed, rendered for stderr.
|
|
144
|
+
*
|
|
145
|
+
* The server answers 2xx here on purpose — the operation ran, and partial
|
|
146
|
+
* failure is modelled on the resource — so the body's `status` is the only
|
|
147
|
+
* signal that anything went wrong. Turning it into a non-zero exit is what
|
|
148
|
+
* stops `&&` chains from reporting a deploy that deployed nothing (#1028).
|
|
149
|
+
*/
|
|
150
|
+
var describeFailedDeploy = args => {
|
|
151
|
+
const {
|
|
152
|
+
commandName,
|
|
153
|
+
data
|
|
154
|
+
} = args;
|
|
155
|
+
if (!DEPLOY_COMMANDS.includes(commandName)) return null;
|
|
156
|
+
if (!isRecord(data) || data.status !== "failed") return null;
|
|
157
|
+
const error = isRecord(data.error) ? data.error : void 0;
|
|
158
|
+
const meta = isRecord(error?.meta) ? error.meta : void 0;
|
|
159
|
+
const logicalId = readString(meta?.logical_id);
|
|
160
|
+
return `${commandName}: the deploy failed${logicalId ? ` at resource '${logicalId}'` : ""} — the formation is 'failed'. ${describeDeployReason({
|
|
161
|
+
error,
|
|
162
|
+
formationId: readString(data.id)
|
|
163
|
+
})}`;
|
|
164
|
+
};
|
|
124
165
|
var formationsWrapper = {
|
|
125
166
|
id: "formations-wrapper",
|
|
126
167
|
commands: FORMATION_COMMANDS,
|
|
168
|
+
failureMessage: describeFailedDeploy,
|
|
127
169
|
helpFlags: [{
|
|
128
170
|
name: "template-path",
|
|
129
171
|
description: "Path to template file (JSON or YAML). Alias: --template-file",
|
|
@@ -332,6 +374,18 @@ var applyWrapperForCommand = args => {
|
|
|
332
374
|
}
|
|
333
375
|
});
|
|
334
376
|
};
|
|
377
|
+
/**
|
|
378
|
+
* What to report for a 2xx payload whose own body says the operation failed,
|
|
379
|
+
* or null when there is nothing wrong with it. Drives the CLI's exit code.
|
|
380
|
+
*/
|
|
381
|
+
var resolveFailureMessage = args => {
|
|
382
|
+
return resolveWrapperForCommand({
|
|
383
|
+
commandName: args.commandName
|
|
384
|
+
})?.failureMessage?.({
|
|
385
|
+
commandName: args.commandName,
|
|
386
|
+
data: args.data
|
|
387
|
+
}) ?? null;
|
|
388
|
+
};
|
|
335
389
|
var getWrapperHelpFlags = commandName => {
|
|
336
390
|
return WRAPPERS.find(w => {
|
|
337
391
|
return w.commands.includes(commandName);
|
|
@@ -1179,7 +1233,7 @@ var routes = {
|
|
|
1179
1233
|
"in": "body"
|
|
1180
1234
|
}, {
|
|
1181
1235
|
"name": "trace_id",
|
|
1182
|
-
"description": "Optional trace ID to group generations",
|
|
1236
|
+
"description": "Optional trace ID to group generations. Each generation appends its own steps to the trace's steps object, and `step_count` covers them all.",
|
|
1183
1237
|
"required": false,
|
|
1184
1238
|
"type": "string",
|
|
1185
1239
|
"in": "body"
|
|
@@ -1590,7 +1644,7 @@ var routes = {
|
|
|
1590
1644
|
"list-ai-provider-models": {
|
|
1591
1645
|
serviceClass: "AIProviders",
|
|
1592
1646
|
operationId: "listAiProviderModels",
|
|
1593
|
-
description: "Asks the provider which models it can run, using this provider record's own credentials and configuration, and returns provider-native model ids — the same strings `default_model` and an agent's `model` carry. Which models are reachable is a property of the credential, not of the provider type: a Vertex provider sees only the publisher models its Google Cloud project and location serve, and a Bedrock provider only the foundation models enabled in its region. Reading the list is how a caller avoids pinning a model that fails at generation time. Not every provider type can answer. `azure` lists deployments an operator named rather than models, and `ollama` lists whatever was pulled onto that host, so both return `400 MODEL_LISTING_UNSUPPORTED`.",
|
|
1647
|
+
description: "Asks the provider which models it can run, using this provider record's own credentials and configuration, and returns provider-native model ids — the same strings `default_model` and an agent's `model` carry. Which models are reachable is a property of the credential, not of the provider type: a Vertex provider sees only the publisher models its Google Cloud project and location serve, and a Bedrock provider only the foundation models enabled in its region. Reading the list is how a caller avoids pinning a model that fails at generation time. Not every provider type can answer. `azure` lists deployments an operator named rather than models, and `ollama` lists whatever was pulled onto that host, so both return `400 MODEL_LISTING_UNSUPPORTED`. Listing resolves credentials the same way generation does, so a record that can generate can list. The API-key types (`openai`, `groq`, `xai`, `gateway`, `custom`, `anthropic`, `google`) use the record's linked secret and cannot list without one. `bedrock` and `vertex` use the linked secret when there is one — IAM keys or a Bedrock API key, a Google service-account key — and otherwise fall back to the server environment (the AWS default credential chain, Google Application Default Credentials), so a record with no `secret_id` can still list. A Vertex record needs no `config.project` when its secret is a service-account key, since the key file names its own project. A Vertex record in express mode (API key) cannot list at all: express mode is a global, project-less endpoint and the publisher-model catalogue is per-project, so it returns `400 MODEL_LISTING_UNSUPPORTED`.",
|
|
1594
1648
|
moduleDocsUrl: "https://soat.ttoss.dev/docs/modules/ai-providers",
|
|
1595
1649
|
httpMethod: "get",
|
|
1596
1650
|
pathParams: ["ai_provider_id"],
|
|
@@ -1644,7 +1698,7 @@ var routes = {
|
|
|
1644
1698
|
"list-api-keys": {
|
|
1645
1699
|
serviceClass: "APIKeys",
|
|
1646
1700
|
operationId: "listApiKeys",
|
|
1647
|
-
description: "Lists API keys accessible to the caller. - JWT admin: returns all API keys. - JWT regular user: returns only the user's own API keys. - API key: returns only API keys scoped to
|
|
1701
|
+
description: "Lists API keys accessible to the caller. - JWT admin: returns all API keys. - JWT regular user: returns only the user's own API keys. - Project-scoped credential (API key or OAuth token): returns only API keys scoped to that project.",
|
|
1648
1702
|
moduleDocsUrl: "https://soat.ttoss.dev/docs/modules/api-keys",
|
|
1649
1703
|
httpMethod: "get",
|
|
1650
1704
|
pathParams: [],
|
|
@@ -1666,7 +1720,7 @@ var routes = {
|
|
|
1666
1720
|
"create-api-key": {
|
|
1667
1721
|
serviceClass: "APIKeys",
|
|
1668
1722
|
operationId: "createApiKey",
|
|
1669
|
-
description: "Creates a new API key for the authenticated user. - `project_id` is optional. When set, the key is scoped to that single project. When omitted or null, the key is **unscoped** and spans every project its owner can reach. - If `policy_ids` is provided, the key's effective permissions are the intersection of the user's policies and the key's policies. - Otherwise the key inherits the user's permissions (confined to the key's project when scoped).",
|
|
1723
|
+
description: "Creates a new API key for the authenticated user. - `project_id` is optional. When set, the key is scoped to that single project. When omitted or null, the key is **unscoped** and spans every project its owner can reach. - If `policy_ids` is provided, the key's effective permissions are the intersection of the user's policies and the key's policies. - Otherwise the key inherits the user's permissions (confined to the key's project when scoped). - When the request is authenticated with a **project-scoped credential**, the new key is confined to that same project: omitting `project_id` defaults to it, naming a different project returns `403 API_KEY_PROJECT_SCOPE`, and `project_id: null` (an unscoped key) is likewise refused. Minting an unscoped key requires an unscoped credential.",
|
|
1670
1724
|
moduleDocsUrl: "https://soat.ttoss.dev/docs/modules/api-keys",
|
|
1671
1725
|
httpMethod: "post",
|
|
1672
1726
|
pathParams: [],
|
|
@@ -1694,7 +1748,7 @@ var routes = {
|
|
|
1694
1748
|
"get-api-key": {
|
|
1695
1749
|
serviceClass: "APIKeys",
|
|
1696
1750
|
operationId: "getApiKey",
|
|
1697
|
-
description: "Returns details of an API key. Only the owner or an admin can access it.",
|
|
1751
|
+
description: "Returns details of an API key. Only the owner or an admin can access it, and a project-scoped credential can only reach keys in its own project.",
|
|
1698
1752
|
moduleDocsUrl: "https://soat.ttoss.dev/docs/modules/api-keys",
|
|
1699
1753
|
httpMethod: "get",
|
|
1700
1754
|
pathParams: ["api_key_id"],
|
|
@@ -1710,7 +1764,7 @@ var routes = {
|
|
|
1710
1764
|
"update-api-key": {
|
|
1711
1765
|
serviceClass: "APIKeys",
|
|
1712
1766
|
operationId: "updateApiKey",
|
|
1713
|
-
description: "Updates an API key's name, project scope, or policies. The project scope can be changed to another project, set (scoping a previously unscoped key), or cleared with null (unscoping the key). Only the owner or an admin can update it.",
|
|
1767
|
+
description: "Updates an API key's name, project scope, or policies. The project scope can be changed to another project, set (scoping a previously unscoped key), or cleared with null (unscoping the key). Only the owner or an admin can update it. A project-scoped credential can only update keys in its own project, and cannot move a key to another project or unscope it.",
|
|
1714
1768
|
moduleDocsUrl: "https://soat.ttoss.dev/docs/modules/api-keys",
|
|
1715
1769
|
httpMethod: "put",
|
|
1716
1770
|
pathParams: ["api_key_id"],
|
|
@@ -1744,7 +1798,7 @@ var routes = {
|
|
|
1744
1798
|
"delete-api-key": {
|
|
1745
1799
|
serviceClass: "APIKeys",
|
|
1746
1800
|
operationId: "deleteApiKey",
|
|
1747
|
-
description: "Deletes an API key. Only the owner or an admin can delete it.",
|
|
1801
|
+
description: "Deletes an API key. Only the owner or an admin can delete it, and a project-scoped credential can only delete keys in its own project.",
|
|
1748
1802
|
moduleDocsUrl: "https://soat.ttoss.dev/docs/modules/api-keys",
|
|
1749
1803
|
httpMethod: "delete",
|
|
1750
1804
|
pathParams: ["api_key_id"],
|
|
@@ -3996,7 +4050,7 @@ var routes = {
|
|
|
3996
4050
|
"create-formation": {
|
|
3997
4051
|
serviceClass: "Formations",
|
|
3998
4052
|
operationId: "createFormation",
|
|
3999
|
-
description: "Validates the template, creates the formation record, then provisions all declared resources in dependency order.",
|
|
4053
|
+
description: "Validates the template, creates the formation record, then provisions all declared resources in dependency order. A **template-shape** error is refused with `400`. A **deploy** failure is not: the operation ran, so the formation is returned with `201` and `status: \"failed\"`, and `error` explains why (the resources created before the failure are rolled back). Read `status` — a `2xx` here means the deploy was attempted, not that it worked. The `soat` CLI exits non-zero on that body so `create-formation && …` does not lie.",
|
|
4000
4054
|
moduleDocsUrl: "https://soat.ttoss.dev/docs/modules/formations",
|
|
4001
4055
|
httpMethod: "post",
|
|
4002
4056
|
pathParams: [],
|
|
@@ -4052,7 +4106,7 @@ var routes = {
|
|
|
4052
4106
|
"update-formation": {
|
|
4053
4107
|
serviceClass: "Formations",
|
|
4054
4108
|
operationId: "updateFormation",
|
|
4055
|
-
description: "Applies a new template to the formation. Resources are created, updated, or deleted to reconcile the current state with the desired state.",
|
|
4109
|
+
description: "Applies a new template to the formation. Resources are created, updated, or deleted to reconcile the current state with the desired state. A **template-shape** error is refused with `400`. A **deploy** failure is not: the operation ran, so the formation is returned with `200` and `status: \"failed\"`, and `error` explains why. Read `status` — a `2xx` here means the deploy was attempted, not that it worked. The `soat` CLI exits non-zero on that body so `update-formation && …` does not lie.",
|
|
4056
4110
|
moduleDocsUrl: "https://soat.ttoss.dev/docs/modules/formations",
|
|
4057
4111
|
httpMethod: "put",
|
|
4058
4112
|
pathParams: ["formation_id"],
|
|
@@ -4227,6 +4281,22 @@ var routes = {
|
|
|
4227
4281
|
"in": "path"
|
|
4228
4282
|
}]
|
|
4229
4283
|
},
|
|
4284
|
+
"get-generation-transcript": {
|
|
4285
|
+
serviceClass: "Generations",
|
|
4286
|
+
operationId: "getGenerationTranscript",
|
|
4287
|
+
description: "Returns one generation's turn read back as an ordered sequence of steps: what it was asked, each model step with its tool calls and results, and how it ended. The transcript is assembled at read time from the generation record and the trace's steps object; nothing is stored, so it cannot outlive the content it projects. Requires `traces:GetTrace` in addition to `generations:GetGeneration`, because the response merges content from both resources. A generation whose content is unavailable — never written under zero-retention, or cleared by a purge — returns `200` with the skeleton rather than an error: `input` and `output` are null, `steps` is empty, and the `content_redacted_*` fields say which happened. `content_redacted_by_principal_id` is `zero_retention` when the content was never stored, and the purging principal's ID when it was erased later. A generation that is still running returns the same shape with an empty `steps`; `status` disambiguates the two.",
|
|
4288
|
+
moduleDocsUrl: "https://soat.ttoss.dev/docs/modules/generations",
|
|
4289
|
+
httpMethod: "get",
|
|
4290
|
+
pathParams: ["generation_id"],
|
|
4291
|
+
queryParams: [],
|
|
4292
|
+
flags: [{
|
|
4293
|
+
"name": "generation_id",
|
|
4294
|
+
"description": "Public ID of the generation",
|
|
4295
|
+
"required": true,
|
|
4296
|
+
"type": "string",
|
|
4297
|
+
"in": "path"
|
|
4298
|
+
}]
|
|
4299
|
+
},
|
|
4230
4300
|
"list-guardrails": {
|
|
4231
4301
|
serviceClass: "Guardrails",
|
|
4232
4302
|
operationId: "listGuardrails",
|
|
@@ -4749,7 +4819,7 @@ var routes = {
|
|
|
4749
4819
|
"in": "body"
|
|
4750
4820
|
}, {
|
|
4751
4821
|
"name": "min_score",
|
|
4752
|
-
"description": "Minimum
|
|
4822
|
+
"description": "Minimum `score` a result must reach to be returned. Filters on the implementation-defined `score`, not on `similarity_score`, so the cutoff follows the ranking. Only applies when `query` is provided. Because the scale behind `score` is not part of the contract, treat a tuned value as tied to the deployment rather than portable.",
|
|
4753
4823
|
"required": false,
|
|
4754
4824
|
"type": "number",
|
|
4755
4825
|
"in": "body"
|
|
@@ -4926,7 +4996,7 @@ var routes = {
|
|
|
4926
4996
|
moduleDocsUrl: "https://soat.ttoss.dev/docs/modules/memoryEntries",
|
|
4927
4997
|
httpMethod: "get",
|
|
4928
4998
|
pathParams: [],
|
|
4929
|
-
queryParams: ["memory_id", "limit", "offset"],
|
|
4999
|
+
queryParams: ["memory_id", "limit", "offset", "include_invalidated"],
|
|
4930
5000
|
flags: [{
|
|
4931
5001
|
"name": "memory_id",
|
|
4932
5002
|
"description": "Memory container to list entries from (mem_...)",
|
|
@@ -4945,6 +5015,12 @@ var routes = {
|
|
|
4945
5015
|
"required": false,
|
|
4946
5016
|
"type": "integer",
|
|
4947
5017
|
"in": "query"
|
|
5018
|
+
}, {
|
|
5019
|
+
"name": "include_invalidated",
|
|
5020
|
+
"description": "Include invalidated (superseded) entries. They are excluded by default; set this to audit the supersede history.",
|
|
5021
|
+
"required": false,
|
|
5022
|
+
"type": "boolean",
|
|
5023
|
+
"in": "query"
|
|
4948
5024
|
}]
|
|
4949
5025
|
},
|
|
4950
5026
|
"create-memory-entry": {
|
|
@@ -6475,6 +6551,80 @@ var routes = {
|
|
|
6475
6551
|
"in": "body"
|
|
6476
6552
|
}]
|
|
6477
6553
|
},
|
|
6554
|
+
"fork-session": {
|
|
6555
|
+
serviceClass: "Sessions",
|
|
6556
|
+
operationId: "forkSession",
|
|
6557
|
+
description: "Branches a new session from a point in this session's history: same context, different continuation. The fork gets its own conversation whose messages **reference the same documents** as the parent rather than copying them, so there is one stored copy of the content and a retention purge erases it from both. Recorded tool results ride along on those messages and are **replayed** as model input on the fork's next turn — forking never re-invokes a tool, so exploring a \"what if\" cannot send an email or charge a card a second time. The consequence to accept is that a forked turn sees the tool data as it was, not as it is now. The fork is created **inert**: `auto_generate` is false and no generation is triggered. Drive it with the normal message and generate endpoints. The fork has no actor — attach one only if the branch is meant to be driven by the same end user, since `single_session_per_actor` agents allow one open session per actor.",
|
|
6558
|
+
moduleDocsUrl: "https://soat.ttoss.dev/docs/modules/sessions",
|
|
6559
|
+
httpMethod: "post",
|
|
6560
|
+
pathParams: ["session_id"],
|
|
6561
|
+
queryParams: [],
|
|
6562
|
+
flags: [{
|
|
6563
|
+
"name": "session_id",
|
|
6564
|
+
"description": "Session public ID",
|
|
6565
|
+
"required": true,
|
|
6566
|
+
"type": "string",
|
|
6567
|
+
"in": "path"
|
|
6568
|
+
}, {
|
|
6569
|
+
"name": "fork_at_position",
|
|
6570
|
+
"description": "The parent conversation `position` to branch after. Messages at positions 0..N are carried into the fork. Omit it to branch at the tip (the whole history).\n",
|
|
6571
|
+
"required": false,
|
|
6572
|
+
"type": "integer",
|
|
6573
|
+
"in": "body"
|
|
6574
|
+
}, {
|
|
6575
|
+
"name": "agent_id",
|
|
6576
|
+
"description": "Agent the fork runs against. Defaults to the parent session's agent; overriding it is the point of forking — same context, a different agent or agent version. Must belong to the same project as the session being forked.\n",
|
|
6577
|
+
"required": false,
|
|
6578
|
+
"type": "string",
|
|
6579
|
+
"in": "body"
|
|
6580
|
+
}, {
|
|
6581
|
+
"name": "name",
|
|
6582
|
+
"description": "Optional name for the forked session",
|
|
6583
|
+
"required": false,
|
|
6584
|
+
"type": "string",
|
|
6585
|
+
"in": "body"
|
|
6586
|
+
}, {
|
|
6587
|
+
"name": "tags",
|
|
6588
|
+
"description": "Optional tags for the forked session",
|
|
6589
|
+
"required": false,
|
|
6590
|
+
"type": "object",
|
|
6591
|
+
"in": "body"
|
|
6592
|
+
}, {
|
|
6593
|
+
"name": "tool_context",
|
|
6594
|
+
"description": "Overrides the parent's `tool_context` on the fork. Omit it and the fork inherits the parent's, so the branch is faithful to the run it came from.\n",
|
|
6595
|
+
"required": false,
|
|
6596
|
+
"type": "object",
|
|
6597
|
+
"in": "body"
|
|
6598
|
+
}]
|
|
6599
|
+
},
|
|
6600
|
+
"list-session-forks": {
|
|
6601
|
+
serviceClass: "Sessions",
|
|
6602
|
+
operationId: "listSessionForks",
|
|
6603
|
+
description: "Returns the sessions forked directly from this one. One level of lineage: a fork of a fork is listed under its own parent.",
|
|
6604
|
+
moduleDocsUrl: "https://soat.ttoss.dev/docs/modules/sessions",
|
|
6605
|
+
httpMethod: "get",
|
|
6606
|
+
pathParams: ["session_id"],
|
|
6607
|
+
queryParams: ["limit", "offset"],
|
|
6608
|
+
flags: [{
|
|
6609
|
+
"name": "session_id",
|
|
6610
|
+
"description": "Session public ID",
|
|
6611
|
+
"required": true,
|
|
6612
|
+
"type": "string",
|
|
6613
|
+
"in": "path"
|
|
6614
|
+
}, {
|
|
6615
|
+
"name": "limit",
|
|
6616
|
+
"description": "",
|
|
6617
|
+
"required": false,
|
|
6618
|
+
"type": "integer",
|
|
6619
|
+
"in": "query"
|
|
6620
|
+
}, {
|
|
6621
|
+
"name": "offset",
|
|
6622
|
+
"description": "",
|
|
6623
|
+
"required": false,
|
|
6624
|
+
"type": "integer",
|
|
6625
|
+
"in": "query"
|
|
6626
|
+
}]
|
|
6627
|
+
},
|
|
6478
6628
|
"get-session-tags": {
|
|
6479
6629
|
serviceClass: "Sessions",
|
|
6480
6630
|
operationId: "getSessionTags",
|
|
@@ -8019,6 +8169,22 @@ var routes = {
|
|
|
8019
8169
|
"in": "path"
|
|
8020
8170
|
}]
|
|
8021
8171
|
},
|
|
8172
|
+
"redeliver-webhook-delivery": {
|
|
8173
|
+
serviceClass: "Webhooks",
|
|
8174
|
+
operationId: "redeliverWebhookDelivery",
|
|
8175
|
+
description: "Queues the stored payload of an existing delivery to be sent again. A new delivery record is created rather than the original being reset, so the original attempt stays in the history. The send happens in the background: poll the returned delivery to observe its outcome.",
|
|
8176
|
+
moduleDocsUrl: "https://soat.ttoss.dev/docs/modules/webhooks",
|
|
8177
|
+
httpMethod: "post",
|
|
8178
|
+
pathParams: ["delivery_id"],
|
|
8179
|
+
queryParams: [],
|
|
8180
|
+
flags: [{
|
|
8181
|
+
"name": "delivery_id",
|
|
8182
|
+
"description": "Delivery to send again (wh_deliv_...)",
|
|
8183
|
+
"required": true,
|
|
8184
|
+
"type": "string",
|
|
8185
|
+
"in": "path"
|
|
8186
|
+
}]
|
|
8187
|
+
},
|
|
8022
8188
|
"get-webhook-secret": {
|
|
8023
8189
|
serviceClass: "Webhooks",
|
|
8024
8190
|
operationId: "getWebhookSecret",
|
|
@@ -8390,6 +8556,76 @@ var findUnknownFlags = args => {
|
|
|
8390
8556
|
}), `Run "soat ${args.commandName} --help" to see the flags this command accepts.`];
|
|
8391
8557
|
};
|
|
8392
8558
|
|
|
8559
|
+
//#endregion
|
|
8560
|
+
//#region src/webhookSignature.ts
|
|
8561
|
+
var digestMatches = args => {
|
|
8562
|
+
const expectedBuffer = Buffer.from(args.expected, "utf8");
|
|
8563
|
+
const actualBuffer = Buffer.from(args.actual, "utf8");
|
|
8564
|
+
if (expectedBuffer.length !== actualBuffer.length) return false;
|
|
8565
|
+
return timingSafeEqual(expectedBuffer, actualBuffer);
|
|
8566
|
+
};
|
|
8567
|
+
/** Deprecated scheme: `sha256=<hex>` over the bare body. */
|
|
8568
|
+
var verifyLegacySignature = args => {
|
|
8569
|
+
const expected = createHmac("sha256", args.secret).update(args.payload).digest("hex");
|
|
8570
|
+
return digestMatches({
|
|
8571
|
+
expected,
|
|
8572
|
+
actual: args.header
|
|
8573
|
+
});
|
|
8574
|
+
};
|
|
8575
|
+
/**
|
|
8576
|
+
* Timestamped scheme: `t=<unix>,v1=<hex>` over `<t>.<body>`.
|
|
8577
|
+
*
|
|
8578
|
+
* The timestamp must be present, but its age is deliberately not enforced here:
|
|
8579
|
+
* this is a local debugging listener, and rejecting a delivery over clock skew
|
|
8580
|
+
* would read as a signing bug. A real subscriber should enforce a tolerance
|
|
8581
|
+
* window — the webhooks module docs show one.
|
|
8582
|
+
*/
|
|
8583
|
+
var verifyTimestampedSignature = args => {
|
|
8584
|
+
const elements = new Map(args.header.split(",").map(part => {
|
|
8585
|
+
const separator = part.indexOf("=");
|
|
8586
|
+
return [part.slice(0, separator).trim(), part.slice(separator + 1)];
|
|
8587
|
+
}));
|
|
8588
|
+
const timestamp = elements.get("t");
|
|
8589
|
+
const digest = elements.get("v1");
|
|
8590
|
+
if (!timestamp || !digest) return false;
|
|
8591
|
+
const expected = createHmac("sha256", args.secret).update(`${timestamp}.${args.payload}`).digest("hex");
|
|
8592
|
+
return digestMatches({
|
|
8593
|
+
expected,
|
|
8594
|
+
actual: digest
|
|
8595
|
+
});
|
|
8596
|
+
};
|
|
8597
|
+
var headerValue = value => {
|
|
8598
|
+
if (Array.isArray(value)) return value[0] ?? "";
|
|
8599
|
+
return value ?? "";
|
|
8600
|
+
};
|
|
8601
|
+
/**
|
|
8602
|
+
* Picks the scheme a delivery used and verifies it, when a secret is supplied.
|
|
8603
|
+
*/
|
|
8604
|
+
var inspectDeliverySignature = args => {
|
|
8605
|
+
const timestamped = headerValue(args.headers["x-soat-signature-v2"]);
|
|
8606
|
+
const legacy = headerValue(args.headers["x-soat-signature"]);
|
|
8607
|
+
const scheme = timestamped ? "v2" : "v1";
|
|
8608
|
+
const signature = timestamped || legacy;
|
|
8609
|
+
if (!args.secret) return {
|
|
8610
|
+
signature,
|
|
8611
|
+
scheme,
|
|
8612
|
+
valid: null
|
|
8613
|
+
};
|
|
8614
|
+
return {
|
|
8615
|
+
signature,
|
|
8616
|
+
scheme,
|
|
8617
|
+
valid: scheme === "v2" ? verifyTimestampedSignature({
|
|
8618
|
+
secret: args.secret,
|
|
8619
|
+
payload: args.payload,
|
|
8620
|
+
header: timestamped
|
|
8621
|
+
}) : verifyLegacySignature({
|
|
8622
|
+
secret: args.secret,
|
|
8623
|
+
payload: args.payload,
|
|
8624
|
+
header: legacy
|
|
8625
|
+
})
|
|
8626
|
+
};
|
|
8627
|
+
};
|
|
8628
|
+
|
|
8393
8629
|
//#endregion
|
|
8394
8630
|
//#region src/index.ts
|
|
8395
8631
|
/**
|
|
@@ -8454,14 +8690,7 @@ var matchesFilter = (eventType, filter) => {
|
|
|
8454
8690
|
return eventType === pattern;
|
|
8455
8691
|
});
|
|
8456
8692
|
};
|
|
8457
|
-
|
|
8458
|
-
const expected = createHmac("sha256", secret).update(payload).digest("hex");
|
|
8459
|
-
const expectedBuffer = Buffer.from(expected, "utf8");
|
|
8460
|
-
const actualBuffer = Buffer.from(signatureHeader, "utf8");
|
|
8461
|
-
if (expectedBuffer.length !== actualBuffer.length) return false;
|
|
8462
|
-
return timingSafeEqual(expectedBuffer, actualBuffer);
|
|
8463
|
-
};
|
|
8464
|
-
program.command("listen").description("Start a local webhook listener for testing deliveries").option("--port <number>", "port to listen on", "8787").option("--path <path>", "request path to accept", "/webhook").option("--secret <secret>", "verify X-Soat-Signature with this webhook secret").option("--filter <pattern>", "filter event type(s), supports prefix wildcard and comma separation (e.g. sessions.generation.*,files.*)").option("--json", "print one JSON object per line").action(opts => {
|
|
8693
|
+
program.command("listen").description("Start a local webhook listener for testing deliveries").option("--port <number>", "port to listen on", "8787").option("--path <path>", "request path to accept", "/webhook").option("--secret <secret>", "verify the delivery signature with this webhook secret").option("--filter <pattern>", "filter event type(s), supports prefix wildcard and comma separation (e.g. sessions.generation.*,files.*)").option("--json", "print one JSON object per line").action(opts => {
|
|
8465
8694
|
const port = Number(opts.port);
|
|
8466
8695
|
const path = opts.path;
|
|
8467
8696
|
const secret = opts.secret;
|
|
@@ -8489,7 +8718,6 @@ program.command("listen").description("Start a local webhook listener for testin
|
|
|
8489
8718
|
const rawBody = Buffer.concat(chunks).toString("utf8");
|
|
8490
8719
|
const eventType = String(req.headers["x-soat-event"] ?? "unknown");
|
|
8491
8720
|
const deliveryId = String(req.headers["x-soat-delivery"] ?? "unknown");
|
|
8492
|
-
const signature = String(req.headers["x-soat-signature"] ?? "");
|
|
8493
8721
|
if (filter && !matchesFilter(eventType, filter)) {
|
|
8494
8722
|
res.writeHead(200, {
|
|
8495
8723
|
"Content-Type": "application/json"
|
|
@@ -8504,24 +8732,28 @@ program.command("listen").description("Start a local webhook listener for testin
|
|
|
8504
8732
|
try {
|
|
8505
8733
|
parsedPayload = JSON.parse(rawBody);
|
|
8506
8734
|
} catch {}
|
|
8507
|
-
|
|
8508
|
-
|
|
8735
|
+
const inspected = inspectDeliverySignature({
|
|
8736
|
+
secret,
|
|
8737
|
+
payload: rawBody,
|
|
8738
|
+
headers: req.headers
|
|
8739
|
+
});
|
|
8509
8740
|
const record = {
|
|
8510
8741
|
timestamp: (/* @__PURE__ */new Date()).toISOString(),
|
|
8511
8742
|
event_type: eventType,
|
|
8512
8743
|
delivery_id: deliveryId,
|
|
8513
|
-
signature,
|
|
8514
|
-
|
|
8744
|
+
signature: inspected.signature,
|
|
8745
|
+
signature_scheme: inspected.scheme,
|
|
8746
|
+
signature_valid: inspected.valid,
|
|
8515
8747
|
payload: parsedPayload
|
|
8516
8748
|
};
|
|
8517
8749
|
if (asJson) console.log(JSON.stringify(record));else {
|
|
8518
8750
|
console.log("--- webhook received ---");
|
|
8519
8751
|
console.log("event_type:", eventType);
|
|
8520
8752
|
console.log("delivery_id:", deliveryId);
|
|
8521
|
-
if (secret) console.log("signature_valid:",
|
|
8753
|
+
if (secret) console.log("signature_valid:", inspected.valid);
|
|
8522
8754
|
console.log("payload:", JSON.stringify(parsedPayload, null, 2));
|
|
8523
8755
|
}
|
|
8524
|
-
const responseStatus = secret &&
|
|
8756
|
+
const responseStatus = secret && inspected.valid === false ? 401 : 200;
|
|
8525
8757
|
res.writeHead(responseStatus, {
|
|
8526
8758
|
"Content-Type": "application/json"
|
|
8527
8759
|
});
|
|
@@ -8529,7 +8761,7 @@ program.command("listen").description("Start a local webhook listener for testin
|
|
|
8529
8761
|
ok: responseStatus === 200,
|
|
8530
8762
|
event_type: eventType,
|
|
8531
8763
|
delivery_id: deliveryId,
|
|
8532
|
-
signature_valid:
|
|
8764
|
+
signature_valid: inspected.valid
|
|
8533
8765
|
}));
|
|
8534
8766
|
});
|
|
8535
8767
|
});
|
|
@@ -8667,6 +8899,14 @@ program.argument("[command]", "API command in kebab-case (e.g. list-actors)").ar
|
|
|
8667
8899
|
process.exit(1);
|
|
8668
8900
|
}
|
|
8669
8901
|
console.log(await formatResultData(result.data));
|
|
8902
|
+
const failure = resolveFailureMessage({
|
|
8903
|
+
commandName,
|
|
8904
|
+
data: result.data
|
|
8905
|
+
});
|
|
8906
|
+
if (failure) {
|
|
8907
|
+
console.error(failure);
|
|
8908
|
+
process.exit(1);
|
|
8909
|
+
}
|
|
8670
8910
|
});
|
|
8671
8911
|
var runCli = async args => {
|
|
8672
8912
|
const previousArgv = process.argv;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soat/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@inquirer/input": "^5.1.2",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"@ttoss/logger": "^0.8.19",
|
|
9
9
|
"commander": "^15.0.0",
|
|
10
10
|
"js-yaml": "^5.2.1",
|
|
11
|
-
"@soat/sdk": "0.
|
|
11
|
+
"@soat/sdk": "0.24.0"
|
|
12
12
|
},
|
|
13
13
|
"devDependencies": {
|
|
14
14
|
"@ttoss/config": "^1.37.17",
|