mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(integrations): close defects found by an independent cold audit (#6767)
* fix(integrations): repair Update SLO and advanced OData filters
An independent audit — eight cold readers, one per integration, given no
prior findings — checked the eight integrations merged to staging today.
Two defects broke an operation outright; both are fixed here.
datadog: Update SLO rewrote every non-metric SLO to `metric`. The SLO Type
dropdown carried a `metric` default and its condition covered both create and
update, so an untouched control reached mergeSloUpdatePayload as an edit. A
metric SLO requires `query`, and the merged body carries monitor_ids or
sli_specification instead, so Datadog rejected it — Update SLO was unusable on
monitor-based and time-slice SLOs, with no way to express "keep the current
type". Update now has its own control defaulting to "Keep current".
microsoft_ad: list_users, list_groups and list_service_principals emitted
$count=true only alongside $search, so any $filter using an advanced operator
(ne, not, endsWith, startsWith on non-indexed properties) returned 400. Graph
requires $count=true with ConsistencyLevel: eventual for those. list_devices
already did this correctly; the other three now match it.
Also from the same audit: Datadog path IDs are trimmed before encoding in all
20 URL builders rather than 2, matching the existing get_monitor test.
* fix(integrations): cloudflare, crowdstrike, and mssql audit findings
From the independent cold audit. Cloudflare: DNS analytics no longer emits
fabricated min/max telemetry (Cloudflare documents both as always empty);
purge_everything defaults to specific-purge and errors when combined with
target lists; three unsourced description claims corrected; Array.isArray
guards on four older list transforms.
CrowdStrike: IOC sort placeholder corrected to the dot form (created_on.desc,
not the nonexistent created_timestamp); the 500-indicator cap relabelled as a
Sim bound rather than a CrowdStrike one; credential failures return 401 rather
than 500; prevent_no_ui noted as unenumerated.
MSSQL: introspect no longer lets the model choose the database; row and byte
caps on reads; introspection collapsed from 4N+2 to 6 fixed queries; WHERE and
identifier guards run before the connection opens so rejections are 400 not
500; SAVE TRANSACTION, OPEN/CLOSE key, DEALLOCATE and ADD SIGNATURE added to
the statement screen as two-token phrases; encrypt wording corrected to say
TDS 7.4 encryption is negotiated, not guaranteed.
* fix(splunk): publish the real output tables and read the errors Splunk sends
The docs generator parses tool source text and resolves a shared `outputs`
const only from the family's `types.ts`, so Splunk's helpers in `utils.ts` were
invisible to it: seven operations published the block's union of every output
instead of their own. Run Search and Get Search Results each shipped a ~50-row
table naming savedSearches, alerts, indexes, and apps they never return, Cancel
Search Job lost `messages`, and the four list tools lost `total`/`offset`.
Inline the four helpers into each consuming tool and delete them, since
relocating a shared const only moves the trap.
Also:
- Add a `splunk-errors` extractor for the documented `{messages: [{type, text}]}`
envelope and set it on all twelve tools. A rejected SPL string, the most common
failure, previously fell through to the status text and reported "Bad Request".
- Read `searchEarliestTime`/`searchLatestTime` with `asNumber`. The job entry
documents them as bare epoch numbers, so `asString` returned null for every
`output_mode=json` response.
- Project the `<messages>` block of the XML job-control response. It is the only
payload that endpoint returns, so `cancel_search_job.messages` was always empty.
- Mark the nullable job outputs optional, matching the transform.
- Default `run_search` to `max_count=1000`. A oneshot search has no paging escape
hatch and Splunk's own default is 10000 rows in one buffered response.
- Add suggested skills to `SplunkBlockMeta`, grounded in `tools.access`.
The regenerated tool metadata also picks up the Cloudflare and MSSQL output
changes from the previous commit, which were never synced.
* fix(okta,servicenow): apply integration audit findings
Cherry-picked from fix/okta-servicenow-audit-followups (6db0f54), whose base
predated the earlier audit round; the duplicate isOktaFlagEnabled that produced
is resolved in favour of the existing richer helper, which already accepts
'yes'/1/'on' as well as true/'true'.
okta: get_logs no longer advertises hasMore forever. A System Log query with no
'until' is a polling query, and Okta always returns a next link for one, even on
an empty page — so any loop driven by hasMore never terminated, including the
one our own shipped skill instructs the agent to run. errorCauses is now
surfaced, so a failed write reports the real reason instead of the useless
'Api validation failed: profile'. sendEmail routes through one coercion helper
across all four lifecycle tools. update_group's declarative fallback throws
rather than silently truncating an extensible group profile.
servicenow: attachmentLimit and limit no longer overwrite each other. Neither
assignment was scoped to an operation, so all 12 paginated operations could
silently return a row count the user never asked for — defeating the block's own
design, which gave attachmentLimit a unique id precisely to avoid this. All
seven approval states are published by ServiceNow and are now reachable from the
filter, with the space-vs-underscore punctuation documented. The five legacy
generic tools route through the shared response helpers, and the folder's only
'any' is gone. Block skills now name the semantic operations.
* chore(integrations): regenerate catalog and docs artifacts
* fix(integrations): disclose MSSQL truncation and keep Okta's poll cursor
Three defects the review round found in the audit fixes themselves.
MSSQL capped a recordset and then reported it as complete: `executeQuery`
computed `truncated`/`truncationReason` but all five statement routes returned
only `message`, `rows`, and `rowCount`, so a caller could not tell paging was
required. A shared `toRowsResponseBody` now folds the reason into `message`
for an agent reading the status line and exposes the two fields for a caller
that branches on them.
The byte ceiling also admitted a single oversized row as a lone exception, so
one `nvarchar(max)` value serialized an unbounded body — the ceiling bounded
everything except the case it exists for. A row is now admitted only when it
still fits, and the drop is disclosed rather than read as an empty table.
Okta's `get_logs` nulled `nextCursor` alongside `hasMore` on an empty polling
page. Terminating the loop is right, but the cursor is the resume handle Okta
tells callers to persist, so a scheduled workflow that hit one quiet interval
restarted from `since` and re-delivered events it had already processed. The
two answer different questions and now diverge.
Cloudflare's purge block no longer lets the invalid combination be built: the
four target fields are hidden once Purge Everything is selected, so the tool's
guard is a backstop rather than a reachable hard error.
* fix(okta,servicenow): stop sending requests the APIs reject
Okta documents `since` and `after` on the System Log as mutually
exclusive, so `get_logs` lets the cursor win rather than sending both —
the shape a scheduled poll that persists the cursor would otherwise send.
Seven boolean query params reached Okta interpolated raw, so an agent
tool call supplying `yes` was rejected. Each now routes through
`isOktaFlagEnabled`, keeping its existing send-or-omit behavior.
A cleared ServiceNow limit/offset/quantity stayed `''` through the block
mapper and was appended as a valueless `sysparm_limit=`. The mapper now
resolves a blank to undefined, and the tools skip a blank as well.
* fix(integrations): mssql guard gaps and Entra query, scope, and output findings
MSSQL read-only screen
- Screen RENAME, documented T-SQL DDL for Azure Synapse dedicated SQL pools and
Analytics Platform System, which are reachable over TDS with exactly the
connection fields this block exposes. `SELECT 1 RENAME OBJECT dbo.t TO t2`
was a schema change passing an operation advertised as read-only.
- Screen the Service Broker family: RECEIVE as a word, and END/MOVE/GET
CONVERSATION and SEND ON CONVERSATION as two-token phrases, since END closes
every CASE. RECEIVE is a destructive read and END CONVERSATION WITH CLEANUP
drops a conversation's messages.
MSSQL routes and block
- Build the insert statement before connecting, matching update and delete, so a
bad identifier answers 400 instead of burning a TLS+login and returning 500.
- Declare `truncated`/`truncationReason` on the block, which the tools declare
and the routes emit but the block left unreferenceable.
Microsoft Entra ID
- Pair `$count=true` with `ConsistencyLevel: eventual` conditionally. Graph
documents `hasMembersWithLicenseErrors`, `isLicenseReconciliationNeeded`, and
`identities/any(i:i/issuer)` as filterable only *without* advanced query
parameters, and documents advanced queries as unsupported in Azure AD B2C
tenants, so the unconditional pair broke filters that previously worked. When
continuing from a nextLink the pairing is read off the link itself.
- Request `LicenseAssignment.Read.All` instead of `Directory.Read.All`. The
latter was needed by `GET /subscribedSkus` alone, whose permission table names
the former as least privileged and does not list the ReadWrite scope we hold.
- Enumerate the block's real output keys instead of a single `response` object
no tool emits.
* fix(splunk,datadog): stop truncating searches and send mute/unmute as query params
Splunk run_search: revert the `max_count=1000` default added last pass. It was
wrong on both halves. Splunk documents the parameter as "the number of events
that can be accessible in any given status bucket. Also, in transforming mode,
the maximum number of results to store" — so for a non-transforming oneshot it
bounds status buckets, not the response, and for a transforming search (`| stats`,
`| timechart`, which is what the block's own skills generate) it capped results
at 1000 where Splunk would have stored 10000, silently. The block's `maxCount`
placeholder already read `10000`, contradicting the code. Send `max_count` only
when the caller sets it and restate the description in Splunk's own terms,
matching create_search_job. The real guidance — a oneshot buffers the whole
result set, so use Create Search Job + Get Search Results for anything large —
moves into the tool description and the search-splunk-logs skill.
Datadog mute/unmute: send `scope`, `end`, and `all_scopes` as query parameters.
`MuteMonitor` and `UnmuteMonitor` declare no `requestBody` in the authoritative
spec (docs.datadoghq.com/resources/json/full_spec_v1.json — the generated
datadog-api-client-go v1 schema omits both operations and is a subset, not the
authority); all three parameters are `in: query`. Sent as a JSON body they are
dropped, so a scoped, time-boxed mute becomes an indefinite mute across every
scope and unmute's "all scopes" never applies — answered with a 200 and the full
monitor object, so nothing surfaces.
Datadog list_monitors: imply `page=0` when a page size is set without a page.
Datadog "returns all monitors without a `page_size` limit" when `page` is absent,
so Page Size was inert from a control that reads as a bound. `page` is not
defaulted when neither is set — that would silently truncate a caller relying on
the documented return-everything behavior.
Also:
- Note in get_fired_alerts that `name=-` returns every saved search's fired
alerts and the endpoint documents "Request parameters: None", so there is no
count/offset to bound it.
- Fix the Splunk block's `messages` output blurb: `[{type, text}]` holds for the
search and job-control operations, but get_search_job returns an object.
- Generalize the Datadog block's numeric coercion (`datadogPageNumber` →
`datadogNumber`) over all 32 bare `Number()` mappings, so a typo or unresolved
reference is omitted rather than sent as `NaN`/`null`, and an explicit `0`
survives the old truthiness guard.
- Disclose create_event's documented 18-hour `date_happened` ceiling, and that
send_logs' `ddsource: "custom"` is a Sim default rather than a Datadog one.
* chore(integrations): regenerate tool metadata and docs
* fix(integrations): resolve confirmed findings from cold block audit
Cloudflare: clear purge_cache advanced targets across operations; send
action_parameters/ref/logging on rate-limit rule updates; migrate off the
deprecated batch zone-settings endpoint; correct MX/URI priority wording;
stop coercing blank numerics to 0.
CrowdStrike: seed includeHidden to match Falcon's documented default.
Microsoft Entra ID: resolve a UPN to an object ID for app role assignment;
wrap 21 array outputs in items.properties so nested paths resolve.
Okta: route assign_user_role's notification flag through isOktaFlagEnabled.
ServiceNow: drop the triage skill's claim of a default limit that does not exist.
Splunk: always assign coerced numerics so raw values cannot leak through the
executor's raw-input merge.
* fix(editor,credential-group): mask secrets outside short-input and stop a per-option abort from failing a shared query
config.password only reached the short-input renderer, so eight credential
fields rendered in plaintext: private keys on ssh/sftp/pi/kalshi, the
Secrets Manager payload, the STS web-identity and SAML assertions, and the
Browser Use variables table. long-input, code, and table now honor the flag.
Code fields mask through the highlighter because react-simple-code-editor
paints its textarea transparent; the table masks every column but the first
so key/value rows stay distinguishable. A registry-walking audit test fails
both on a password flag sitting on a type that cannot honor it and on any of
the eight fields losing its flag.
credential-group threaded a per-option AbortSignal into the fetch registered
under the workspace-wide credential group list key, so closing one option
panel rejected every co-observer with an AbortError that is not a React
Query cancellation. The shared fetch now runs on its own lifecycle signal.
* fix(mssql,editor): measure the response cap in UTF-8 and stop search from unmasking secrets
capRecordset sized rows with JSON.stringify(row).length, which counts UTF-16
code units while the emitted body carries raw UTF-8. CJK is the worst case at
3 bytes per unit, so a recordset admitted as 10 MB serialized to 28 MB. Rows
are now measured with Buffer.byteLength, serialized once each, with array
punctuation charged exactly and a reserve held back for the response envelope.
Workflow search revealed masked credentials without the user touching the
field: the search panel keeps focus in its own input and only scrolls the
match into view, so typing a guess painted a private key on screen. The index
is built client-side from values already in page memory, so this was never a
privilege boundary, but masking exists to prevent incidental display and a
screenshare-visible reveal defeats it. Focus is now the only reveal, applied
through one shared policy across all four renderers.
* test(editor): drop PEM-shaped fixtures from the masking tests
The masking fixtures carried a literal OPENSSH private key header, which
GitGuardian flags as a committed secret even though the body was only the
base64 of "openssh-key-v1". The fixtures now use an obvious marker string,
and the assertions derive their match text and dot counts from the fixture
instead of restating its bytes.
* refactor(editor): drop the dead isSearchHighlighted prop
No renderer consumed it. The editor computed it at two call sites and
sub-block passed a hardcoded false into renderLabel's slot for it, so even
the one function that declared a parameter never saw the real value. Its
only live effect was in the memo comparator, where an unconsumed value
changing forced a re-render for nothing.
The name stays in the masking audit's forbidden-inputs list, which guards
against a search signal being wired back into a masking decision.
This commit is contained in:
@@ -61,7 +61,7 @@ Lists all zones (domains) in the Cloudflare account.
|
||||
| ↳ `name` | string | Domain name |
|
||||
| ↳ `status` | string | Zone status \(initializing, pending, active, moved\) |
|
||||
| ↳ `paused` | boolean | Whether the zone is paused |
|
||||
| ↳ `type` | string | Zone type \(full, partial, or secondary\) |
|
||||
| ↳ `type` | string | Zone type \(full, partial, secondary, or internal\) |
|
||||
| ↳ `name_servers` | array | Assigned Cloudflare name servers |
|
||||
| ↳ `original_name_servers` | array | Original name servers before moving to Cloudflare |
|
||||
| ↳ `created_on` | string | ISO 8601 date when the zone was created |
|
||||
@@ -114,7 +114,7 @@ Gets details for a specific zone (domain) by its ID.
|
||||
| `name` | string | Domain name |
|
||||
| `status` | string | Zone status \(initializing, pending, active, moved\) |
|
||||
| `paused` | boolean | Whether the zone is paused |
|
||||
| `type` | string | Zone type \(full, partial, or secondary\) |
|
||||
| `type` | string | Zone type \(full, partial, secondary, or internal\) |
|
||||
| `name_servers` | array | Assigned Cloudflare name servers |
|
||||
| `original_name_servers` | array | Original name servers before moving to Cloudflare |
|
||||
| `created_on` | string | ISO 8601 date when the zone was created |
|
||||
@@ -157,7 +157,7 @@ Adds a new zone (domain) to the Cloudflare account.
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `name` | string | Yes | The domain name to add \(e.g., "example.com"\) |
|
||||
| `accountId` | string | Yes | The Cloudflare account ID |
|
||||
| `type` | string | No | Zone type: "full" \(Cloudflare manages DNS\), "partial" \(CNAME setup\), or "secondary" \(secondary DNS\) |
|
||||
| `type` | string | No | Zone type: "full" \(Cloudflare manages DNS\), "partial" \(CNAME setup\), or "secondary" \(secondary DNS\). Cloudflare also defines "internal", which is not creatable through this tool |
|
||||
| `apiKey` | string | Yes | Cloudflare API Token |
|
||||
|
||||
#### Output
|
||||
@@ -168,7 +168,7 @@ Adds a new zone (domain) to the Cloudflare account.
|
||||
| `name` | string | Domain name |
|
||||
| `status` | string | Zone status \(initializing, pending, active, moved\) |
|
||||
| `paused` | boolean | Whether the zone is paused |
|
||||
| `type` | string | Zone type \(full, partial, or secondary\) |
|
||||
| `type` | string | Zone type \(full, partial, secondary, or internal\) |
|
||||
| `name_servers` | array | Assigned Cloudflare name servers |
|
||||
| `original_name_servers` | array | Original name servers before moving to Cloudflare |
|
||||
| `created_on` | string | ISO 8601 date when the zone was created |
|
||||
@@ -257,7 +257,7 @@ Lists DNS records for a specific zone.
|
||||
| ↳ `proxied` | boolean | Whether Cloudflare proxy is enabled |
|
||||
| ↳ `ttl` | number | TTL in seconds \(1 = automatic\) |
|
||||
| ↳ `locked` | boolean | Whether the record is locked |
|
||||
| ↳ `priority` | number | MX/SRV record priority |
|
||||
| ↳ `priority` | number | Record priority, returned for MX and URI records |
|
||||
| ↳ `comment` | string | Comment associated with the record |
|
||||
| ↳ `tags` | array | Tags associated with the record |
|
||||
| ↳ `comment_modified_on` | string | ISO 8601 timestamp when the comment was last modified |
|
||||
@@ -326,7 +326,7 @@ Updates an existing DNS record for a zone.
|
||||
| `content` | string | No | DNS record content \(e.g., IP address\) |
|
||||
| `ttl` | number | No | Time to live in seconds \(1 = automatic\) |
|
||||
| `proxied` | boolean | No | Whether to enable Cloudflare proxy |
|
||||
| `priority` | number | No | Priority for MX and SRV records |
|
||||
| `priority` | number | No | Record priority. Cloudflare accepts this top-level field for MX and URI records only; an SRV record carries its priority, weight, port, and target inside the record content instead |
|
||||
| `comment` | string | No | Comment for the DNS record |
|
||||
| `tags` | string | No | Comma-separated tags for the DNS record |
|
||||
| `apiKey` | string | Yes | Cloudflare API Token |
|
||||
@@ -345,7 +345,7 @@ Updates an existing DNS record for a zone.
|
||||
| `proxied` | boolean | Whether Cloudflare proxy is enabled |
|
||||
| `ttl` | number | Time to live in seconds \(1 = automatic\) |
|
||||
| `locked` | boolean | Whether the record is locked |
|
||||
| `priority` | number | Priority for MX and SRV records |
|
||||
| `priority` | number | Record priority, returned for MX and URI records |
|
||||
| `comment` | string | Comment associated with the record |
|
||||
| `tags` | array | Tags associated with the record |
|
||||
| `comment_modified_on` | string | ISO 8601 timestamp when the comment was last modified |
|
||||
@@ -440,25 +440,29 @@ Lists SSL/TLS certificate packs for a zone.
|
||||
|
||||
### Cloudflare Get Zone Settings
|
||||
|
||||
Gets all settings for a zone including SSL mode, caching level, and security settings.
|
||||
Reads zone settings such as SSL mode, minimum TLS version, security level, and caching level. Cloudflare retired the endpoint that read every setting in one request, so each setting is read individually — name the ones you need to keep the read small. Defaults to $\{DEFAULT_ZONE_SETTING_IDS.join(', ')\}.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `zoneId` | string | Yes | The zone ID to get settings for |
|
||||
| `settingIds` | string | No | Comma-separated setting IDs to read, e.g. "ssl,min_tls_version,security_level". Leave blank to read the default set \($\{DEFAULT_ZONE_SETTING_IDS.join\(', '\)\}\). At most $\{MAX_ZONE_SETTING_IDS\} settings per call. |
|
||||
| `apiKey` | string | Yes | Cloudflare API Token |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `settings` | array | List of zone settings |
|
||||
| `settings` | array | The zone settings that were readable |
|
||||
| ↳ `id` | string | Setting identifier \(e.g., ssl, cache_level, security_level, always_use_https\) |
|
||||
| ↳ `value` | string | Setting value as a string. Simple values returned as-is \(e.g., "full", "on"\). Complex values are JSON-stringified \(e.g., \{"css":"on","html":"on","js":"on"\}\). |
|
||||
| ↳ `editable` | boolean | Whether the setting can be modified for the current zone plan |
|
||||
| ↳ `modified_on` | string | ISO 8601 timestamp when the setting was last modified |
|
||||
| ↳ `time_remaining` | number | Seconds remaining until the setting can be modified again \(only present for rate-limited settings\) |
|
||||
| ↳ `time_remaining` | number | Development mode countdown, in seconds. Cloudflare documents this only on the zones_development_mode setting, where it is the interval from when development mode expires \(positive\) or last expired \(negative\) |
|
||||
| `unreadable` | array | Requested settings Cloudflare refused, typically because the zone plan does not expose them or the setting ID does not exist |
|
||||
| ↳ `id` | string | The requested setting identifier |
|
||||
| ↳ `error` | string | Why Cloudflare would not return the setting |
|
||||
|
||||
### Cloudflare Update Zone Setting
|
||||
|
||||
@@ -481,7 +485,7 @@ Updates a specific zone setting such as SSL mode, security level, cache level, o
|
||||
| `value` | string | Updated setting value as a string. Simple values returned as-is \(e.g., "full", "on"\). Complex values are JSON-stringified. |
|
||||
| `editable` | boolean | Whether the setting can be modified for the current zone plan |
|
||||
| `modified_on` | string | ISO 8601 timestamp when the setting was last modified |
|
||||
| `time_remaining` | number | Seconds remaining until the setting can be modified again \(only present for rate-limited settings\) |
|
||||
| `time_remaining` | number | Development mode countdown, in seconds. Cloudflare documents this only on the zones_development_mode setting, where it is the interval from when development mode expires \(positive\) or last expired \(negative\) |
|
||||
|
||||
### Cloudflare DNS Analytics
|
||||
|
||||
@@ -494,7 +498,7 @@ Gets DNS analytics report for a zone including query counts and trends.
|
||||
| `zoneId` | string | Yes | The zone ID to get DNS analytics for |
|
||||
| `since` | string | No | Start date for analytics \(ISO 8601, e.g., "2024-01-01T00:00:00Z"\) or relative \(e.g., "-6h"\) |
|
||||
| `until` | string | No | End date for analytics \(ISO 8601, e.g., "2024-01-31T23:59:59Z"\) or relative \(e.g., "now"\) |
|
||||
| `metrics` | string | No | Comma-separated metrics to retrieve \(e.g., "queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedian,responseTime90th,responseTime99th"\). Optional — Cloudflare returns its default metric set when it is omitted |
|
||||
| `metrics` | string | No | Comma-separated metrics to retrieve \(e.g., "queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedian,responseTime90th,responseTime99th"\). Optional in the API |
|
||||
| `dimensions` | string | No | Comma-separated dimensions to group by \(e.g., "queryName,queryType,responseCode,responseCached,coloName,origin,dayOfWeek,tcp,ipVersion,querySizeBucket,responseSizeBucket"\) |
|
||||
| `filters` | string | No | Filters to apply to the data \(e.g., "queryType==A"\) |
|
||||
| `sort` | string | No | Sort order for the result set. Fields must be included in metrics or dimensions \(e.g., "+queryCount" or "-responseTimeAvg"\) |
|
||||
@@ -505,30 +509,16 @@ Gets DNS analytics report for a zone including query counts and trends.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `totals` | object | Aggregate DNS analytics totals for the entire queried period |
|
||||
| ↳ `queryCount` | number | Total number of DNS queries |
|
||||
| ↳ `uncachedCount` | number | Number of uncached DNS queries |
|
||||
| ↳ `staleCount` | number | Number of stale DNS queries |
|
||||
| `totals` | object | Aggregate DNS analytics totals for the entire queried period. Only the metrics that were requested are present. |
|
||||
| ↳ `queryCount` | number | Total number of DNS queries. Absent when queryCount was not requested |
|
||||
| ↳ `uncachedCount` | number | Number of uncached DNS queries. Absent when uncachedCount was not requested |
|
||||
| ↳ `staleCount` | number | Number of stale DNS queries. Absent when staleCount was not requested |
|
||||
| ↳ `responseTimeAvg` | number | Average response time in milliseconds |
|
||||
| ↳ `responseTimeMedian` | number | Median response time in milliseconds |
|
||||
| ↳ `responseTime90th` | number | 90th percentile response time in milliseconds |
|
||||
| ↳ `responseTime99th` | number | 99th percentile response time in milliseconds |
|
||||
| `min` | object | Minimum values across the analytics period |
|
||||
| ↳ `queryCount` | number | Minimum number of DNS queries |
|
||||
| ↳ `uncachedCount` | number | Minimum number of uncached DNS queries |
|
||||
| ↳ `staleCount` | number | Minimum number of stale DNS queries |
|
||||
| ↳ `responseTimeAvg` | number | Minimum average response time in milliseconds |
|
||||
| ↳ `responseTimeMedian` | number | Minimum median response time in milliseconds |
|
||||
| ↳ `responseTime90th` | number | Minimum 90th percentile response time in milliseconds |
|
||||
| ↳ `responseTime99th` | number | Minimum 99th percentile response time in milliseconds |
|
||||
| `max` | object | Maximum values across the analytics period |
|
||||
| ↳ `queryCount` | number | Maximum number of DNS queries |
|
||||
| ↳ `uncachedCount` | number | Maximum number of uncached DNS queries |
|
||||
| ↳ `staleCount` | number | Maximum number of stale DNS queries |
|
||||
| ↳ `responseTimeAvg` | number | Maximum average response time in milliseconds |
|
||||
| ↳ `responseTimeMedian` | number | Maximum median response time in milliseconds |
|
||||
| ↳ `responseTime90th` | number | Maximum 90th percentile response time in milliseconds |
|
||||
| ↳ `responseTime99th` | number | Maximum 99th percentile response time in milliseconds |
|
||||
| `min` | json | Per-metric minimums. Cloudflare documents this field as currently always an empty object, so treat a populated value as unexpected rather than relied upon. |
|
||||
| `max` | json | Per-metric maximums. Cloudflare documents this field as currently always an empty object, so treat a populated value as unexpected rather than relied upon. |
|
||||
| `data` | array | Raw analytics data rows returned by the Cloudflare DNS analytics report |
|
||||
| ↳ `dimensions` | array | Dimension values for this data row, parallel to the requested dimensions list |
|
||||
| ↳ `metrics` | array | Metric values for this data row, parallel to the requested metrics list |
|
||||
@@ -908,7 +898,7 @@ Creates a rate limiting rule in the http_ratelimit phase entry point ruleset of
|
||||
| `zoneId` | string | Yes | The zone ID to add the rate limiting rule to |
|
||||
| `rulesetId` | string | Yes | The http_ratelimit entry point ruleset ID, as returned by "List Rate Limiting Rules" |
|
||||
| `expression` | string | Yes | Cloudflare filter expression selecting the requests the rule applies to, e.g. \(http.request.uri.path matches "^/api/"\) |
|
||||
| `characteristics` | string | Yes | Comma-separated counting characteristics. cf.colo.id is mandatory, plus exactly one of ip.src or cf.unique_visitor_id. Example: cf.colo.id,ip.src |
|
||||
| `characteristics` | string | Yes | Comma-separated counting characteristics. cf.colo.id is mandatory. ip.src and cf.unique_visitor_id are mutually exclusive — include at most one. Example: cf.colo.id,ip.src |
|
||||
| `period` | number | Yes | Counting window in seconds. Cloudflare accepts only 10, 60, 120, 300, 600, or 3600 |
|
||||
| `requestsPerPeriod` | number | Yes | Number of requests allowed within the counting period before the action fires |
|
||||
| `action` | string | No | Action applied once the limit is exceeded, e.g. block, managed_challenge, js_challenge, challenge, or log. Defaults to block |
|
||||
@@ -956,7 +946,7 @@ Updates a rate limiting rule in the http_ratelimit phase entry point ruleset of
|
||||
| `rulesetId` | string | Yes | The http_ratelimit entry point ruleset ID, as returned by "List Rate Limiting Rules" |
|
||||
| `ruleId` | string | Yes | The rate limiting rule ID to update |
|
||||
| `expression` | string | Yes | Cloudflare filter expression selecting the requests the rule applies to |
|
||||
| `characteristics` | string | Yes | Comma-separated counting characteristics. cf.colo.id is mandatory, plus exactly one of ip.src or cf.unique_visitor_id |
|
||||
| `characteristics` | string | Yes | Comma-separated counting characteristics. cf.colo.id is mandatory. ip.src and cf.unique_visitor_id are mutually exclusive — include at most one. |
|
||||
| `period` | number | Yes | Counting window in seconds. Cloudflare accepts only 10, 60, 120, 300, 600, or 3600 |
|
||||
| `requestsPerPeriod` | number | Yes | Number of requests allowed within the counting period before the action fires |
|
||||
| `action` | string | Yes | Action applied once the limit is exceeded: block, managed_challenge, js_challenge, challenge, or log. Required because this endpoint replaces the rule rather than merging into it — a defaulted action would silently convert an existing log or challenge rule into a hard block |
|
||||
@@ -965,6 +955,9 @@ Updates a rate limiting rule in the http_ratelimit phase entry point ruleset of
|
||||
| `requestsToOrigin` | boolean | No | When true, only requests that reach the origin are counted |
|
||||
| `description` | string | No | Human-readable description of the rule |
|
||||
| `enabled` | boolean | No | Whether the rule is enabled |
|
||||
| `ref` | string | No | Reference tag that stays stable across rule updates. Because the update replaces the rule, omitting it resets the tag to the rule ID and breaks anything matching on the old value |
|
||||
| `actionParameters` | string | No | JSON object of action-specific parameters for the mitigation action, e.g. \{"response":\{"status_code":429,"content":"\{\\"error\\":\\"rate limited\\"\}","content_type":"application/json"\}\} for a custom block response. Because the update replaces the rule, omitting it resets action_parameters to \{\} and the rule falls back to Cloudflare\'s default block page |
|
||||
| `logging` | string | No | JSON logging configuration to preserve, e.g. \{"enabled":true\}. Omitting it on a rule that had logging configured resets it to the default |
|
||||
| `apiKey` | string | Yes | Cloudflare API Token |
|
||||
|
||||
#### Output
|
||||
@@ -1114,7 +1107,7 @@ Creates a Cloudflare Access (Zero Trust) application that puts an identity check
|
||||
|
||||
### Cloudflare Update Access Application
|
||||
|
||||
Updates a Cloudflare Access (Zero Trust) application. This replaces the application definition rather than merging it, so send every field the application should keep — anything you omit reverts to its default, which can widen or break access. Read the current configuration with "Get Access Application" first. Requires an API token with Account Access: Apps and Policies Edit.
|
||||
Updates a Cloudflare Access (Zero Trust) application. Cloudflare does not document merge behavior for this PUT, so treat it as a replace: send every field the application should keep, because an omitted field may revert to its default and widen or break access. Read the current configuration with "Get Access Application" first. Requires an API token with Account Access: Apps and Policies Edit.
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -1256,7 +1249,7 @@ Creates a Cloudflare Access (Zero Trust) policy on an application, deciding who
|
||||
|
||||
### Cloudflare Update Access Policy
|
||||
|
||||
Updates a Cloudflare Access (Zero Trust) policy on an application. This replaces the policy definition rather than merging it, so send every rule the policy should keep — omitted exclude or require rules are dropped, which can widen who gets in. The change applies to live traffic immediately. Read the current policy with "List Access Policies" first. Requires an API token with Account Access: Apps and Policies Edit.
|
||||
Updates a Cloudflare Access (Zero Trust) policy on an application. Cloudflare does not document merge behavior for this PUT, so treat it as a replace: send every rule the policy should keep, because an omitted exclude or require rule may be dropped and widen who gets in. The change applies to live traffic immediately. Read the current policy with "List Access Policies" first. Requires an API token with Account Access: Apps and Policies Edit.
|
||||
|
||||
#### Input
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ Create custom CrowdStrike Falcon indicators of compromise (POST /iocs/entities/i
|
||||
| `clientId` | string | Yes | CrowdStrike Falcon API client ID |
|
||||
| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret |
|
||||
| `cloud` | string | Yes | CrowdStrike Falcon cloud region |
|
||||
| `indicators` | json | Yes | JSON array of indicators to create. Each entry requires type, value, and applied_globally \(boolean\). type is one of sha256, md5, domain, ipv4, ipv6; action is one of no_action, allow, prevent_no_ui, prevent, detect; severity is one of informational, low, medium, high, critical; platforms entries are windows, mac, or linux. Other documented fields: host_groups \(array\), description, source, tags \(array\), expiration \(ISO 8601\), mobile_action, metadata \(\{ filename \}\). Either applied_globally must be true or host_groups must be supplied. Tenants can extend these value sets, so treat them as the documented defaults rather than a closed list. |
|
||||
| `indicators` | json | Yes | JSON array of indicators to create. Each entry requires type, value, and applied_globally \(boolean\). type is one of sha256, md5, domain, ipv4, ipv6; action is one of no_action, allow, prevent, detect \(prevent_no_ui is widely reported and appears in the Falcon console, but CrowdStrike does not enumerate it in the IOC API docs - call GET /iocs/queries/actions/v1 to read the actions your tenant actually accepts\); severity is one of informational, low, medium, high, critical; platforms entries are windows, mac, or linux. Other documented fields: host_groups \(array\), description, source, tags \(array\), expiration \(ISO 8601\), mobile_action, metadata \(\{ filename \}\). Either applied_globally must be true or host_groups must be supplied. Tenants can extend these value sets, so treat them as the documented defaults rather than a closed list. |
|
||||
| `comment` | string | No | Audit comment explaining why these indicators were created |
|
||||
| `retrodetects` | boolean | No | Whether to generate retroactive detections for the new indicators |
|
||||
| `ignoreWarnings` | boolean | No | Whether to create the indicators even when CrowdStrike returns warnings |
|
||||
@@ -774,7 +774,7 @@ Search custom CrowdStrike Falcon indicators of compromise (IOCs) with a Falcon Q
|
||||
| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret |
|
||||
| `cloud` | string | Yes | CrowdStrike Falcon cloud region |
|
||||
| `filter` | string | No | Falcon Query Language filter over IOC fields |
|
||||
| `limit` | number | No | Maximum number of IOC IDs to return \(1-500, default 100\) |
|
||||
| `limit` | number | No | Maximum number of IOC IDs to return \(default 100\). CrowdStrike publishes no maximum for this endpoint; Sim caps it at 500 to keep a single request bounded |
|
||||
| `offset` | number | No | Pagination offset. Mutually exclusive with the after cursor; use after beyond 10,000 IOCs. |
|
||||
| `after` | string | No | Pagination cursor from a previous response. Mutually exclusive with offset. |
|
||||
| `sort` | string | No | Sort expression. Supported fields include action, applied_globally, created_by, created_on, expiration, expired, modified_by, modified_on, severity_number, source, type, and value. |
|
||||
|
||||
@@ -95,7 +95,7 @@ Post an event to the Datadog event stream. Use for deployment notifications, ale
|
||||
| `tags` | string | No | Comma-separated list of tags \(e.g., "env:production,service:api", "team:backend,priority:high"\) |
|
||||
| `aggregationKey` | string | No | Key to aggregate events together |
|
||||
| `sourceTypeName` | string | No | Source type name for the event |
|
||||
| `dateHappened` | number | No | Unix timestamp in seconds when the event occurred \(e.g., 1705320000, defaults to now\) |
|
||||
| `dateHappened` | number | No | Unix timestamp in seconds when the event occurred \(e.g., 1705320000, defaults to now\). Datadog limits this to events no older than 18 hours. |
|
||||
| `apiKey` | string | Yes | Datadog API key |
|
||||
| `site` | string | No | Datadog site/region \(default: datadoghq.com\) |
|
||||
|
||||
@@ -197,8 +197,8 @@ List all monitors in Datadog with optional filtering by name, tags, or state.
|
||||
| `tags` | string | No | Comma-separated list of tags to filter by \(e.g., "env:prod,team:backend"\) |
|
||||
| `monitorTags` | string | No | Comma-separated list of monitor tags to filter by \(e.g., "service:api,priority:high"\) |
|
||||
| `withDowntimes` | boolean | No | Include downtime data with monitors |
|
||||
| `page` | number | No | Page number for pagination \(0-indexed, e.g., 0, 1, 2\) |
|
||||
| `pageSize` | number | No | Number of monitors per page \(e.g., 50, max: 1000\) |
|
||||
| `page` | number | No | Page to start paginating from \(0-indexed, e.g., 0, 1, 2\). Datadog returns every monitor in the org without pagination when this is not specified, so set it to bound the response. Setting Page Size alone implies page 0. |
|
||||
| `pageSize` | number | No | Number of monitors per page \(e.g., 50, max: 1000\). Datadog only applies this when a page is specified — otherwise it returns all monitors with no page size limit — so setting this alone sends page 0. With a page but no page size, Datadog defaults to 100. |
|
||||
| `apiKey` | string | Yes | Datadog API key |
|
||||
| `applicationKey` | string | Yes | Datadog Application key |
|
||||
| `site` | string | No | Datadog site/region \(default: datadoghq.com\) |
|
||||
@@ -312,7 +312,7 @@ Send log entries to Datadog for centralized logging and analysis.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `logs` | string | Yes | JSON array of log entries. Each entry should have message and optionally ddsource, ddtags, hostname, service. |
|
||||
| `logs` | string | Yes | JSON array of log entries. Each entry should have message and optionally ddsource, ddtags, hostname, service. Sim fills in ddsource="custom" when an entry omits it — that is a Sim default, not a Datadog one; set ddsource yourself to have Datadog apply the matching integration log pipeline. |
|
||||
| `apiKey` | string | Yes | Datadog API key |
|
||||
| `site` | string | No | Datadog site/region \(default: datadoghq.com\) |
|
||||
|
||||
|
||||
@@ -55,6 +55,17 @@ List users in Azure AD (Microsoft Entra ID)
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `users` | array | List of users |
|
||||
| ↳ `id` | string | User ID |
|
||||
| ↳ `displayName` | string | Display name |
|
||||
| ↳ `givenName` | string | First name |
|
||||
| ↳ `surname` | string | Last name |
|
||||
| ↳ `userPrincipalName` | string | User principal name \(email\) |
|
||||
| ↳ `mail` | string | Email address |
|
||||
| ↳ `jobTitle` | string | Job title |
|
||||
| ↳ `department` | string | Department |
|
||||
| ↳ `officeLocation` | string | Office location |
|
||||
| ↳ `mobilePhone` | string | Mobile phone number |
|
||||
| ↳ `accountEnabled` | boolean | Whether the account is enabled |
|
||||
| `userCount` | number | Number of users returned |
|
||||
| `nextLink` | string | Continuation URL for the next page of results, or null if there are no more |
|
||||
|
||||
@@ -182,6 +193,16 @@ List groups in Azure AD (Microsoft Entra ID)
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `groups` | array | List of groups |
|
||||
| ↳ `id` | string | Group ID |
|
||||
| ↳ `displayName` | string | Display name |
|
||||
| ↳ `description` | string | Group description |
|
||||
| ↳ `mail` | string | Email address |
|
||||
| ↳ `mailEnabled` | boolean | Whether mail is enabled |
|
||||
| ↳ `mailNickname` | string | Mail nickname |
|
||||
| ↳ `securityEnabled` | boolean | Whether security is enabled |
|
||||
| ↳ `groupTypes` | array | Group types |
|
||||
| ↳ `visibility` | string | Group visibility |
|
||||
| ↳ `createdDateTime` | string | Creation date |
|
||||
| `groupCount` | number | Number of groups returned |
|
||||
| `nextLink` | string | Continuation URL for the next page of results, or null if there are no more |
|
||||
|
||||
@@ -298,6 +319,10 @@ List members of a group in Azure AD (Microsoft Entra ID)
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `members` | array | List of group members |
|
||||
| ↳ `id` | string | Member ID |
|
||||
| ↳ `displayName` | string | Display name |
|
||||
| ↳ `mail` | string | Email address |
|
||||
| ↳ `odataType` | string | Directory object type |
|
||||
| `memberCount` | number | Number of members returned |
|
||||
| `nextLink` | string | Continuation URL for the next page of results, or null if there are no more |
|
||||
|
||||
@@ -360,6 +385,8 @@ Add or remove subscription licenses (SKUs) on a user in Microsoft Entra ID. Remo
|
||||
| `displayName` | string | Display name of the user |
|
||||
| `userPrincipalName` | string | User principal name of the user |
|
||||
| `assignedLicenses` | array | Licenses assigned to the user after the change |
|
||||
| ↳ `skuId` | string | SKU ID of the assigned license |
|
||||
| ↳ `disabledPlans` | array | Service plan IDs disabled on this license |
|
||||
|
||||
### List Microsoft Entra ID User Licenses
|
||||
|
||||
@@ -376,6 +403,14 @@ List the subscription licenses assigned to a user in Microsoft Entra ID
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `licenses` | array | Licenses assigned to the user |
|
||||
| ↳ `id` | string | License detail ID |
|
||||
| ↳ `skuId` | string | SKU ID of the license |
|
||||
| ↳ `skuPartNumber` | string | SKU part number \(e.g., "ENTERPRISEPACK"\) |
|
||||
| ↳ `servicePlans` | array | Service plans included in the license |
|
||||
| ↳ `servicePlanId` | string | Service plan ID |
|
||||
| ↳ `servicePlanName` | string | Service plan name |
|
||||
| ↳ `provisioningStatus` | string | Provisioning status of the service plan |
|
||||
| ↳ `appliesTo` | string | Whether the plan applies to "User" or "Company" |
|
||||
| `licenseCount` | number | Number of licenses returned |
|
||||
|
||||
### List Microsoft Entra ID Subscribed SKUs
|
||||
@@ -392,6 +427,22 @@ List the subscription SKUs the tenant owns, including how many license units are
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `skus` | array | Subscription SKUs owned by the tenant |
|
||||
| ↳ `id` | string | Subscribed SKU object ID |
|
||||
| ↳ `skuId` | string | SKU ID, used when assigning or removing licenses |
|
||||
| ↳ `skuPartNumber` | string | SKU part number \(e.g., "ENTERPRISEPACK"\) |
|
||||
| ↳ `appliesTo` | string | Whether the SKU applies to "User" or "Company" |
|
||||
| ↳ `capabilityStatus` | string | Capability status of the subscription |
|
||||
| ↳ `consumedUnits` | number | Number of licenses currently assigned |
|
||||
| ↳ `prepaidUnits` | object | Prepaid license unit counts by status |
|
||||
| ↳ `enabled` | number | Number of units that are enabled |
|
||||
| ↳ `suspended` | number | Number of units that are suspended |
|
||||
| ↳ `warning` | number | Number of units that are in warning status |
|
||||
| ↳ `lockedOut` | number | Number of units that are locked out |
|
||||
| ↳ `servicePlans` | array | Service plans included in the SKU |
|
||||
| ↳ `servicePlanId` | string | Service plan ID |
|
||||
| ↳ `servicePlanName` | string | Service plan name |
|
||||
| ↳ `provisioningStatus` | string | Provisioning status of the service plan |
|
||||
| ↳ `appliesTo` | string | Whether the plan applies to "User" or "Company" |
|
||||
| `skuCount` | number | Number of SKUs returned |
|
||||
|
||||
### Revoke Microsoft Entra ID Sign-In Sessions
|
||||
@@ -467,6 +518,9 @@ List the authentication methods a user has registered, such as passwords, phone
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `methods` | array | Authentication methods registered by the user |
|
||||
| ↳ `id` | string | Authentication method ID |
|
||||
| ↳ `odataType` | string | Authentication method type \(e.g., "#microsoft.graph.phoneAuthenticationMethod"\). Method-specific details vary by type. |
|
||||
| ↳ `createdDateTime` | string | When the authentication method was registered |
|
||||
| `methodCount` | number | Number of authentication methods returned |
|
||||
|
||||
### List Microsoft Entra ID Sign-Ins
|
||||
@@ -486,6 +540,34 @@ List sign-in events from the Microsoft Entra ID sign-in logs, newest first. Requ
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `signIns` | array | Sign-in events |
|
||||
| ↳ `id` | string | Sign-in event ID |
|
||||
| ↳ `createdDateTime` | string | When the sign-in was initiated |
|
||||
| ↳ `userId` | string | ID of the user who signed in |
|
||||
| ↳ `userDisplayName` | string | Display name of the user |
|
||||
| ↳ `userPrincipalName` | string | User principal name of the user |
|
||||
| ↳ `appId` | string | ID of the application used to sign in |
|
||||
| ↳ `appDisplayName` | string | Display name of the application |
|
||||
| ↳ `resourceId` | string | ID of the resource that was accessed |
|
||||
| ↳ `resourceDisplayName` | string | Display name of the resource |
|
||||
| ↳ `ipAddress` | string | IP address the sign-in came from |
|
||||
| ↳ `clientAppUsed` | string | Legacy client app used to sign in |
|
||||
| ↳ `correlationId` | string | Correlation ID for the sign-in request |
|
||||
| ↳ `conditionalAccessStatus` | string | Conditional access result: success, failure, notApplied, or unknownFutureValue |
|
||||
| ↳ `isInteractive` | boolean | Whether the sign-in was interactive |
|
||||
| ↳ `riskDetail` | string | Reason behind a specific risk state |
|
||||
| ↳ `riskLevelAggregated` | string | Aggregated risk level for the sign-in |
|
||||
| ↳ `riskState` | string | Risk state of the user or sign-in |
|
||||
| ↳ `errorCode` | number | Sign-in status error code. 0 indicates a successful sign-in. |
|
||||
| ↳ `failureReason` | string | Failure reason from the sign-in status |
|
||||
| ↳ `deviceDisplayName` | string | Display name of the device used |
|
||||
| ↳ `deviceId` | string | ID of the device used |
|
||||
| ↳ `deviceOperatingSystem` | string | Operating system of the device used |
|
||||
| ↳ `deviceBrowser` | string | Browser used to sign in |
|
||||
| ↳ `deviceIsCompliant` | boolean | Whether the device is compliant |
|
||||
| ↳ `deviceIsManaged` | boolean | Whether the device is managed |
|
||||
| ↳ `locationCity` | string | City the sign-in came from |
|
||||
| ↳ `locationState` | string | State the sign-in came from |
|
||||
| ↳ `locationCountryOrRegion` | string | Two-letter country or region code the sign-in came from |
|
||||
| `signInCount` | number | Number of sign-ins returned |
|
||||
| `nextLink` | string | Continuation URL for the next page of results, or null if there are no more |
|
||||
|
||||
@@ -506,6 +588,25 @@ List directory audit records showing who changed what in Microsoft Entra ID, suc
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `audits` | array | Directory audit records |
|
||||
| ↳ `id` | string | Audit record ID |
|
||||
| ↳ `activityDateTime` | string | When the activity took place |
|
||||
| ↳ `activityDisplayName` | string | Name of the activity |
|
||||
| ↳ `category` | string | Category of the activity |
|
||||
| ↳ `correlationId` | string | Correlation ID for the activity |
|
||||
| ↳ `loggedByService` | string | Service that logged the activity |
|
||||
| ↳ `operationType` | string | Operation type \(e.g., Add, Update, Delete\) |
|
||||
| ↳ `result` | string | Result of the activity: success, failure, timeout, or unknownFutureValue |
|
||||
| ↳ `resultReason` | string | Reason for the result |
|
||||
| ↳ `initiatedByUserId` | string | ID of the user who initiated the activity |
|
||||
| ↳ `initiatedByUserPrincipalName` | string | User principal name of the initiating user |
|
||||
| ↳ `initiatedByUserDisplayName` | string | Display name of the initiating user |
|
||||
| ↳ `initiatedByAppId` | string | App ID that initiated the activity |
|
||||
| ↳ `initiatedByAppDisplayName` | string | Display name of the app that initiated the activity |
|
||||
| ↳ `targetResources` | array | Resources the activity acted on |
|
||||
| ↳ `id` | string | ID of the target resource |
|
||||
| ↳ `displayName` | string | Display name of the target resource |
|
||||
| ↳ `type` | string | Type of the target resource \(e.g., User, Group\) |
|
||||
| ↳ `userPrincipalName` | string | User principal name of the target, null for non-user resources |
|
||||
| `auditCount` | number | Number of audit records returned |
|
||||
| `nextLink` | string | Continuation URL for the next page of results, or null if there are no more |
|
||||
|
||||
@@ -527,6 +628,14 @@ List the application role assignments granted to a user, including assignments t
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `assignments` | array | App role assignments granted to the user |
|
||||
| ↳ `id` | string | App role assignment ID, used when removing the assignment |
|
||||
| ↳ `appRoleId` | string | ID of the app role. All-zero GUID means the assignment grants access without a specific role. |
|
||||
| ↳ `createdDateTime` | string | When the assignment was created |
|
||||
| ↳ `principalId` | string | ID of the assigned principal |
|
||||
| ↳ `principalDisplayName` | string | Display name of the assigned principal |
|
||||
| ↳ `principalType` | string | Principal type: User, Group, or ServicePrincipal |
|
||||
| ↳ `resourceId` | string | ID of the resource service principal that defines the app role |
|
||||
| ↳ `resourceDisplayName` | string | Display name of the resource |
|
||||
| `assignmentCount` | number | Number of assignments returned |
|
||||
| `nextLink` | string | Continuation URL for the next page of results, or null if there are no more |
|
||||
|
||||
@@ -538,7 +647,7 @@ Grant a user an application role on a service principal, giving them access to t
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `userId` | string | Yes | User ID or user principal name to grant the app role to |
|
||||
| `userId` | string | Yes | Object ID or user principal name of the user to grant the app role to. A user principal name is resolved to its object ID before the grant. |
|
||||
| `resourceId` | string | Yes | Object ID of the resource service principal that defines the app role. Use List Service Principals to find it. |
|
||||
| `appRoleId` | string | Yes | ID of the app role to grant. Use the all-zero GUID 00000000-0000-0000-0000-000000000000 to assign access without a specific role. |
|
||||
|
||||
@@ -593,6 +702,21 @@ List the enterprise applications and service principals in the tenant, including
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `servicePrincipals` | array | Service principals in the tenant |
|
||||
| ↳ `id` | string | Service principal object ID, used as the resource ID of an app role assignment |
|
||||
| ↳ `appId` | string | Application ID associated with the service principal |
|
||||
| ↳ `displayName` | string | Display name of the service principal |
|
||||
| ↳ `servicePrincipalType` | string | Type of service principal \(e.g., Application, ManagedIdentity, Legacy\) |
|
||||
| ↳ `accountEnabled` | boolean | Whether users can sign in to the associated application |
|
||||
| ↳ `appOwnerOrganizationId` | string | Tenant ID where the application is registered |
|
||||
| ↳ `signInAudience` | string | Which Microsoft accounts are supported by the associated application |
|
||||
| ↳ `tags` | array | Custom strings used to categorize the service principal |
|
||||
| ↳ `appRoles` | array | App roles exposed by the associated application |
|
||||
| ↳ `id` | string | App role ID, used when granting an app role assignment |
|
||||
| ↳ `displayName` | string | Display name of the app role |
|
||||
| ↳ `description` | string | Description of the app role |
|
||||
| ↳ `value` | string | Value included in the roles claim for this app role |
|
||||
| ↳ `isEnabled` | boolean | Whether the app role can be assigned |
|
||||
| ↳ `allowedMemberTypes` | array | Principal types the app role can be assigned to \(User and/or Application\) |
|
||||
| `servicePrincipalCount` | number | Number of service principals returned |
|
||||
| `nextLink` | string | Continuation URL for the next page of results, or null if there are no more |
|
||||
|
||||
@@ -613,6 +737,14 @@ List every user, group, and service principal assigned to an application, by rea
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `assignments` | array | Principals assigned to the application |
|
||||
| ↳ `id` | string | App role assignment ID, used when removing the assignment |
|
||||
| ↳ `appRoleId` | string | ID of the app role. All-zero GUID means the assignment grants access without a specific role. |
|
||||
| ↳ `createdDateTime` | string | When the assignment was created |
|
||||
| ↳ `principalId` | string | ID of the assigned principal |
|
||||
| ↳ `principalDisplayName` | string | Display name of the assigned principal |
|
||||
| ↳ `principalType` | string | Principal type: User, Group, or ServicePrincipal |
|
||||
| ↳ `resourceId` | string | ID of the resource service principal that defines the app role |
|
||||
| ↳ `resourceDisplayName` | string | Display name of the resource |
|
||||
| `assignmentCount` | number | Number of assignments returned |
|
||||
| `nextLink` | string | Continuation URL for the next page of results, or null if there are no more |
|
||||
|
||||
@@ -630,6 +762,10 @@ List the administrator roles that are activated in the tenant, such as Global Ad
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `roles` | array | Activated directory roles |
|
||||
| ↳ `id` | string | Directory role object ID |
|
||||
| ↳ `displayName` | string | Display name of the directory role |
|
||||
| ↳ `description` | string | Description of the directory role |
|
||||
| ↳ `roleTemplateId` | string | ID of the directory role template |
|
||||
| `roleCount` | number | Number of directory roles returned |
|
||||
|
||||
### List Microsoft Entra ID Directory Role Members
|
||||
@@ -647,6 +783,10 @@ List the principals holding an administrator role. Returns up to 1000 members; t
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `members` | array | Principals holding the directory role |
|
||||
| ↳ `id` | string | Member ID |
|
||||
| ↳ `displayName` | string | Display name |
|
||||
| ↳ `mail` | string | Email address |
|
||||
| ↳ `odataType` | string | Directory object type |
|
||||
| `memberCount` | number | Number of members returned |
|
||||
|
||||
### Add Microsoft Entra ID Directory Role Member
|
||||
@@ -705,6 +845,20 @@ List the devices registered in Microsoft Entra ID
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `devices` | array | Registered devices |
|
||||
| ↳ `id` | string | Device object ID, used to get, update, or delete the device |
|
||||
| ↳ `deviceId` | string | Unique device identifier set during registration |
|
||||
| ↳ `displayName` | string | Display name of the device |
|
||||
| ↳ `operatingSystem` | string | Operating system of the device |
|
||||
| ↳ `operatingSystemVersion` | string | Operating system version of the device |
|
||||
| ↳ `accountEnabled` | boolean | Whether the device is enabled |
|
||||
| ↳ `isCompliant` | boolean | Whether the device complies with MDM policies |
|
||||
| ↳ `isManaged` | boolean | Whether the device is managed by an MDM app |
|
||||
| ↳ `trustType` | string | Device registration type: Workplace, AzureAd, or ServerAd |
|
||||
| ↳ `profileType` | string | Device profile type: RegisteredDevice, SecureVM, Printer, Shared, or IoT |
|
||||
| ↳ `manufacturer` | string | Manufacturer of the device |
|
||||
| ↳ `model` | string | Model of the device |
|
||||
| ↳ `approximateLastSignInDateTime` | string | Approximate time the device last signed in |
|
||||
| ↳ `registrationDateTime` | string | When the device was registered |
|
||||
| `deviceCount` | number | Number of devices returned |
|
||||
| `nextLink` | string | Continuation URL for the next page of results, or null if there are no more |
|
||||
|
||||
@@ -756,6 +910,20 @@ List the devices a user has registered or owns. Devices the caller cannot read a
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `devices` | array | Devices linked to the user |
|
||||
| ↳ `id` | string | Device object ID, used to get, update, or delete the device |
|
||||
| ↳ `deviceId` | string | Unique device identifier set during registration |
|
||||
| ↳ `displayName` | string | Display name of the device |
|
||||
| ↳ `operatingSystem` | string | Operating system of the device |
|
||||
| ↳ `operatingSystemVersion` | string | Operating system version of the device |
|
||||
| ↳ `accountEnabled` | boolean | Whether the device is enabled |
|
||||
| ↳ `isCompliant` | boolean | Whether the device complies with MDM policies |
|
||||
| ↳ `isManaged` | boolean | Whether the device is managed by an MDM app |
|
||||
| ↳ `trustType` | string | Device registration type: Workplace, AzureAd, or ServerAd |
|
||||
| ↳ `profileType` | string | Device profile type: RegisteredDevice, SecureVM, Printer, Shared, or IoT |
|
||||
| ↳ `manufacturer` | string | Manufacturer of the device |
|
||||
| ↳ `model` | string | Model of the device |
|
||||
| ↳ `approximateLastSignInDateTime` | string | Approximate time the device last signed in |
|
||||
| ↳ `registrationDateTime` | string | When the device was registered |
|
||||
| `deviceCount` | number | Number of devices returned |
|
||||
| `nextLink` | string | Continuation URL for the next page of results, or null if there are no more |
|
||||
|
||||
@@ -776,6 +944,15 @@ List the conditional access policies configured in the tenant, including their s
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `policies` | array | Conditional access policies |
|
||||
| ↳ `id` | string | Conditional access policy ID |
|
||||
| ↳ `displayName` | string | Display name of the policy |
|
||||
| ↳ `state` | string | Policy state: enabled, disabled, or enabledForReportingButNotEnforced |
|
||||
| ↳ `templateId` | string | ID of the template the policy was created from |
|
||||
| ↳ `createdDateTime` | string | When the policy was created |
|
||||
| ↳ `modifiedDateTime` | string | When the policy was last modified |
|
||||
| ↳ `conditions` | json | Conditions that trigger the policy \(users, applications, platforms, locations, risk levels\) |
|
||||
| ↳ `grantControls` | json | Controls enforced when the policy applies, or null when none are configured |
|
||||
| ↳ `sessionControls` | json | Session controls enforced when the policy applies, or null when none are set |
|
||||
| `policyCount` | number | Number of policies returned |
|
||||
| `nextLink` | string | Continuation URL for the next page of results, or null if there are no more |
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Execute a SELECT query on a Microsoft SQL Server database
|
||||
| `database` | string | Yes | Database name to connect to |
|
||||
| `username` | string | Yes | Database username |
|
||||
| `password` | string | Yes | Database password |
|
||||
| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext |
|
||||
| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee |
|
||||
| `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle |
|
||||
| `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) |
|
||||
| `query` | string | Yes | T-SQL SELECT query to execute, optionally led by a WITH clause. Statements that modify data or schema are rejected — use the Execute Raw SQL operation for those. |
|
||||
@@ -43,6 +43,8 @@ Execute a SELECT query on a Microsoft SQL Server database
|
||||
| `message` | string | Operation status message |
|
||||
| `rows` | array | Array of rows returned from the query |
|
||||
| `rowCount` | number | Number of rows returned |
|
||||
| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete |
|
||||
| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows |
|
||||
|
||||
### Microsoft SQL Server Insert
|
||||
|
||||
@@ -57,7 +59,7 @@ Insert data into a Microsoft SQL Server table
|
||||
| `database` | string | Yes | Database name to connect to |
|
||||
| `username` | string | Yes | Database username |
|
||||
| `password` | string | Yes | Database password |
|
||||
| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext |
|
||||
| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee |
|
||||
| `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle |
|
||||
| `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) |
|
||||
| `table` | string | Yes | Table name to insert data into |
|
||||
@@ -70,6 +72,8 @@ Insert data into a Microsoft SQL Server table
|
||||
| `message` | string | Operation status message |
|
||||
| `rows` | array | Rows returned by the statement \(empty for a plain INSERT\) |
|
||||
| `rowCount` | number | Number of rows inserted |
|
||||
| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete |
|
||||
| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows |
|
||||
|
||||
### Microsoft SQL Server Update
|
||||
|
||||
@@ -84,7 +88,7 @@ Update rows in a Microsoft SQL Server table
|
||||
| `database` | string | Yes | Database name to connect to |
|
||||
| `username` | string | Yes | Database username |
|
||||
| `password` | string | Yes | Database password |
|
||||
| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext |
|
||||
| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee |
|
||||
| `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle |
|
||||
| `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) |
|
||||
| `table` | string | Yes | Table name to update |
|
||||
@@ -98,6 +102,8 @@ Update rows in a Microsoft SQL Server table
|
||||
| `message` | string | Operation status message |
|
||||
| `rows` | array | Rows returned by the statement \(empty for a plain UPDATE\) |
|
||||
| `rowCount` | number | Number of rows updated |
|
||||
| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete |
|
||||
| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows |
|
||||
|
||||
### Microsoft SQL Server Delete
|
||||
|
||||
@@ -112,7 +118,7 @@ Delete rows from a Microsoft SQL Server table
|
||||
| `database` | string | Yes | Database name to connect to |
|
||||
| `username` | string | Yes | Database username |
|
||||
| `password` | string | Yes | Database password |
|
||||
| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext |
|
||||
| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee |
|
||||
| `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle |
|
||||
| `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) |
|
||||
| `table` | string | Yes | Table name to delete rows from |
|
||||
@@ -125,6 +131,8 @@ Delete rows from a Microsoft SQL Server table
|
||||
| `message` | string | Operation status message |
|
||||
| `rows` | array | Rows returned by the statement \(empty for a plain DELETE\) |
|
||||
| `rowCount` | number | Number of rows deleted |
|
||||
| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete |
|
||||
| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows |
|
||||
|
||||
### Microsoft SQL Server Execute
|
||||
|
||||
@@ -139,7 +147,7 @@ Execute a raw T-SQL statement on a Microsoft SQL Server database
|
||||
| `database` | string | Yes | Database name to connect to |
|
||||
| `username` | string | Yes | Database username |
|
||||
| `password` | string | Yes | Database password |
|
||||
| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext |
|
||||
| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee |
|
||||
| `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle |
|
||||
| `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) |
|
||||
| `query` | string | Yes | T-SQL statement to execute |
|
||||
@@ -151,6 +159,8 @@ Execute a raw T-SQL statement on a Microsoft SQL Server database
|
||||
| `message` | string | Operation status message |
|
||||
| `rows` | array | Rows returned by the statement, when it returns a result set |
|
||||
| `rowCount` | number | Number of rows returned or affected |
|
||||
| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete |
|
||||
| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows |
|
||||
|
||||
### Microsoft SQL Server Introspect
|
||||
|
||||
@@ -165,7 +175,7 @@ Introspect a Microsoft SQL Server schema to retrieve table structures, columns,
|
||||
| `database` | string | Yes | Database name to connect to |
|
||||
| `username` | string | Yes | Database username |
|
||||
| `password` | string | Yes | Database password |
|
||||
| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext |
|
||||
| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee |
|
||||
| `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle |
|
||||
| `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) |
|
||||
| `schema` | string | No | Schema to introspect \(default: dbo\) |
|
||||
@@ -176,6 +186,29 @@ Introspect a Microsoft SQL Server schema to retrieve table structures, columns,
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `tables` | array | Array of table schemas with columns, keys, and indexes |
|
||||
| ↳ `name` | string | Table name |
|
||||
| ↳ `schema` | string | Schema name \(e.g., dbo\) |
|
||||
| ↳ `columns` | array | Table columns in ordinal position order |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Data type \(e.g., int, nvarchar, datetime2\) |
|
||||
| ↳ `nullable` | boolean | Whether the column allows NULL values |
|
||||
| ↳ `default` | string | Default value expression |
|
||||
| ↳ `isPrimaryKey` | boolean | Whether the column is part of the primary key |
|
||||
| ↳ `isForeignKey` | boolean | Whether the column is a foreign key |
|
||||
| ↳ `references` | object | Foreign key reference information |
|
||||
| ↳ `schema` | string | Referenced schema name |
|
||||
| ↳ `table` | string | Referenced table name |
|
||||
| ↳ `column` | string | Referenced column name |
|
||||
| ↳ `primaryKey` | array | Primary key column names, in key order |
|
||||
| ↳ `foreignKeys` | array | Foreign key constraints declared on this table |
|
||||
| ↳ `column` | string | Local column name |
|
||||
| ↳ `referencesSchema` | string | Referenced schema name |
|
||||
| ↳ `referencesTable` | string | Referenced table name |
|
||||
| ↳ `referencesColumn` | string | Referenced column name |
|
||||
| ↳ `indexes` | array | Non-primary-key rowstore indexes on this table |
|
||||
| ↳ `name` | string | Index name |
|
||||
| ↳ `columns` | array | Key columns included in the index, in key order |
|
||||
| ↳ `unique` | boolean | Whether the index enforces uniqueness |
|
||||
| `schemas` | array | List of available schemas in the database |
|
||||
|
||||
|
||||
|
||||
@@ -280,7 +280,7 @@ Generate a one-time token to reset a user password. Can email the reset link to
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `userId` | string | Yes | User ID or login to reset password for |
|
||||
| `sendEmail` | boolean | No | Send password reset email to the user \(default: true\) |
|
||||
| `sendEmail` | boolean | No | Send password reset email to the user. Okta requires this parameter and declares no default of its own; leaving it blank sends the email |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -1219,7 +1219,7 @@ Query the Okta System Log for sign-ins, admin changes, and security events. Supp
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `since` | string | No | Start of the query time window as an ISO 8601 timestamp \(default: 7 days before "until"\) |
|
||||
| `since` | string | No | Start of the query time window as an ISO 8601 timestamp \(default: 7 days before "until"\). Ignored when a cursor is supplied in "after", which already encodes the resume position |
|
||||
| `until` | string | No | End of the query time window as an ISO 8601 timestamp \(default: now\) |
|
||||
| `filter` | string | No | SCIM filter expression \(e.g., eventType eq "user.session.start" or outcome.result eq "FAILURE"\) |
|
||||
| `q` | string | No | Keyword search across the event payload \(max 40 characters per keyword, max 10 keywords\) |
|
||||
@@ -1267,8 +1267,8 @@ Query the Okta System Log for sign-ins, admin changes, and security events. Supp
|
||||
| ↳ `displayName` | string | Target display name |
|
||||
| ↳ `debugData` | json | Extra context whose keys depend on the event type. Okta states these keys and values can change between releases, so treat them as a debugging aid rather than a contract |
|
||||
| `count` | number | Number of events returned |
|
||||
| `nextCursor` | string | Cursor for the next page, or null on the last page |
|
||||
| `hasMore` | boolean | Whether more events are available |
|
||||
| `nextCursor` | string | Cursor to resume from, or null when Okta advertised no next link. On a polling query it stays set on an empty page so the next scheduled run resumes from here rather than replaying from the start |
|
||||
| `hasMore` | boolean | Whether more events are available. A query with no "until" is a polling query, which Okta always answers with a next link even when there are no new events, so this reports false once a page comes back empty |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
|
||||
|
||||
@@ -354,7 +354,7 @@ Search ServiceNow incidents by state, priority, assignment, caller, or text. All
|
||||
| `callerId` | string | No | sys_id of the caller. |
|
||||
| `active` | string | No | Restrict to active \("true"\) or inactive \("false"\) incidents. |
|
||||
| `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. |
|
||||
| `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). |
|
||||
| `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. |
|
||||
| `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). |
|
||||
@@ -739,7 +739,7 @@ Search ServiceNow change requests by state, type, risk, assignment, or text. All
|
||||
| `assignedTo` | string | No | sys_id of the assigned user. |
|
||||
| `active` | string | No | Restrict to active \("true"\) or inactive \("false"\) change requests. |
|
||||
| `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. |
|
||||
| `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). |
|
||||
| `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. |
|
||||
| `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). |
|
||||
@@ -1032,7 +1032,7 @@ List requested items (RITMs) from the ServiceNow Requested Item [sc_req_item] ta
|
||||
| `catalogItemSysId` | string | No | sys_id of the catalog item \(cat_item\) to filter by. |
|
||||
| `active` | string | No | Restrict to active \("true"\) or inactive \("false"\) requested items. |
|
||||
| `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. |
|
||||
| `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). |
|
||||
| `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. |
|
||||
| `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). |
|
||||
@@ -1132,7 +1132,7 @@ List approval records from the ServiceNow Approval [sysapproval_approver] table.
|
||||
| `state` | string | No | Approval state: "requested" \(pending, the default\), "approved", or "rejected". Pass an empty string with a custom query to list every state. |
|
||||
| `approvalFor` | string | No | sys_id of the record being approved, matched against the sysapproval reference field. |
|
||||
| `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. |
|
||||
| `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). |
|
||||
| `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. |
|
||||
| `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). |
|
||||
@@ -1234,7 +1234,7 @@ Search the ServiceNow CMDB for configuration items. Defaults to the base cmdb_ci
|
||||
| `name` | string | No | Text to match against the CI name using the ServiceNow LIKE operator, which matches anywhere in the field. |
|
||||
| `operationalStatus` | string | No | Operational status coded value \(operational_status\). The choice list is configured per instance. |
|
||||
| `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. |
|
||||
| `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). |
|
||||
| `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. |
|
||||
| `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). |
|
||||
@@ -1316,7 +1316,7 @@ List rows from the CI Relationship [cmdb_rel_ci] table for a configuration item.
|
||||
| `ciSysId` | string | Yes | sys_id of the configuration item whose relationships should be listed. |
|
||||
| `direction` | string | No | Which side of the relationship the CI sits on: "parent", "child", or "both" \(default\). "both" matches rows where the CI is either the parent or the child. |
|
||||
| `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. |
|
||||
| `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). |
|
||||
| `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. |
|
||||
| `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). |
|
||||
@@ -1436,7 +1436,7 @@ Look up ServiceNow users by email, user name, or display name. Use this to resol
|
||||
| `name` | string | No | Text to match against the display name using the ServiceNow LIKE operator, which matches anywhere in the field. |
|
||||
| `active` | string | No | Restrict to active \("true"\) or inactive \("false"\) users. |
|
||||
| `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. |
|
||||
| `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). |
|
||||
| `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. |
|
||||
| `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). |
|
||||
@@ -1487,7 +1487,7 @@ List the members of a ServiceNow group from the Group Member [sys_user_grmember]
|
||||
| `groupSysId` | string | No | sys_id of the sys_user_group whose members should be listed. |
|
||||
| `groupName` | string | No | Exact group name, resolved against the referenced group record. Provide this or the group sys_id. |
|
||||
| `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). |
|
||||
| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. |
|
||||
| `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). |
|
||||
| `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. |
|
||||
| `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). |
|
||||
|
||||
@@ -20,7 +20,7 @@ Integrate Splunk Enterprise or Splunk Cloud into workflows. Run SPL searches syn
|
||||
|
||||
### Splunk Run Search
|
||||
|
||||
Run an SPL search synchronously and return its results in a single call (oneshot mode). Use for short searches; use Create Search Job for long-running ones.
|
||||
Run an SPL search synchronously and return its results in a single call (oneshot mode). A oneshot search buffers the whole result set in one response with no paging, so use it for short searches; for anything large use Create Search Job with Get Search Results, which defaults to 100 rows and pages with offset.
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -37,62 +37,19 @@ Run an SPL search synchronously and return its results in a single call (oneshot
|
||||
| `latestTime` | string | No | Latest \(exclusive\) time bound — relative \(e.g. now\) or absolute time |
|
||||
| `adhocSearchLevel` | string | No | Search mode: verbose, fast, or smart. Defaults to fast. |
|
||||
| `autoCancel` | number | No | Cancel the search after this many seconds of inactivity \(e.g. 60\). 0 never auto-cancels. |
|
||||
| `maxCount` | number | No | Maximum number of results the search stores and returns. Defaults to 10000. Lower it to bound large oneshot responses. |
|
||||
| `maxCount` | number | No | Number of events accessible in any given status bucket, and in transforming mode the maximum number of results to store. Defaults to 10000. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `results` | json | Search result rows, each holding the fields the search produced |
|
||||
| `resultCount` | number | Number of result rows returned |
|
||||
| `preview` | boolean | Whether the results are previews |
|
||||
| `initOffset` | number | Offset of the first returned row |
|
||||
| `messages` | json | Messages returned with the response \(\[\{type, text\}\]\) |
|
||||
| `sid` | string | Search ID of the job |
|
||||
| `label` | string | Custom name of the search job |
|
||||
| `dispatchState` | string | Current state of the search job |
|
||||
| `doneProgress` | number | Approximate job progress between 0 and 1 |
|
||||
| `isDone` | boolean | Whether the search has completed |
|
||||
| `isFailed` | boolean | Whether the search failed |
|
||||
| `isFinalized` | boolean | Whether the search was finalized |
|
||||
| `isPaused` | boolean | Whether the search is paused |
|
||||
| `isZombie` | boolean | Whether the search process died |
|
||||
| `isSaved` | boolean | Whether the job artifacts are saved |
|
||||
| `isSavedSearch` | boolean | Whether the job came from a saved search |
|
||||
| `isRealTimeSearch` | boolean | Whether this is a real-time search |
|
||||
| `eventCount` | number | Number of events returned |
|
||||
| `eventAvailableCount` | number | Number of events available for export |
|
||||
| `eventFieldCount` | number | Number of fields found in the results |
|
||||
| `resultPreviewCount` | number | Number of rows in the latest preview |
|
||||
| `scanCount` | number | Number of events scanned off disk |
|
||||
| `runDuration` | number | Seconds the search took to complete |
|
||||
| `priority` | number | Search priority between 0 and 10 |
|
||||
| `earliestTime` | string | Earliest time bound of the job |
|
||||
| `latestTime` | string | Latest time bound of the job |
|
||||
| `searchEarliestTime` | string | Earliest time as specified in the search command |
|
||||
| `searchLatestTime` | string | Latest time as specified in the search command |
|
||||
| `savedSearches` | json | Saved searches \(\[\{name, id, author, updated, search, description, disabled, isScheduled, cronSchedule, alertType\}\]\) |
|
||||
| `name` | string | Saved search name |
|
||||
| `id` | string | Fully qualified REST URI of the resource |
|
||||
| `author` | string | Owner of the saved search |
|
||||
| `updated` | string | Last update timestamp |
|
||||
| `search` | string | SPL the saved search runs |
|
||||
| `qualifiedSearch` | string | Exact search string the scheduler runs |
|
||||
| `description` | string | Saved search description |
|
||||
| `disabled` | boolean | Whether the saved search is disabled |
|
||||
| `isScheduled` | boolean | Whether the search runs on a schedule |
|
||||
| `isVisible` | boolean | Whether the search is listed as visible |
|
||||
| `cronSchedule` | string | Cron schedule for the search |
|
||||
| `nextScheduledTime` | string | Next scheduled run time |
|
||||
| `alertType` | string | Alert condition type |
|
||||
| `dispatchEarliestTime` | string | Earliest time used when dispatching |
|
||||
| `dispatchLatestTime` | string | Latest time used when dispatching |
|
||||
| `alerts` | json | Saved searches with currently triggered alerts \(\[\{name, id, updated, triggeredAlertCount\}\]\) |
|
||||
| `firedAlerts` | json | Triggered instances of an alert \(\[\{name, savedSearchName, alertType, severity, sid, triggerTime\}\]\) |
|
||||
| `indexes` | json | Indexes configured on the instance \(\[\{name, datatype, disabled, totalEventCount, currentDBSizeMB, maxTotalDataSizeMB, minTime, maxTime\}\]\) |
|
||||
| `apps` | json | Apps installed on the instance \(name, label, version, author, disabled\) |
|
||||
| `total` | number | Total number of entries matching a list request, from the response paging envelope. Compare with offset to decide whether another page remains. |
|
||||
| `offset` | number | Offset of the first entry in the returned page, from the paging envelope |
|
||||
| `results` | array | Result rows. Each row holds the fields produced by the search. |
|
||||
| `resultCount` | number | Number of result rows returned in this response |
|
||||
| `preview` | boolean | Whether these are preview results from a still-running job |
|
||||
| `initOffset` | number | Offset of the first returned row within the full result set |
|
||||
| `messages` | array | Search messages returned alongside the results |
|
||||
| ↳ `type` | string | Message severity |
|
||||
| ↳ `text` | string | Message text |
|
||||
|
||||
### Splunk Create Search Job
|
||||
|
||||
@@ -169,8 +126,8 @@ Get the status and progress of a Splunk search job by search ID, including dispa
|
||||
| `priority` | number | Search priority between 0 and 10 |
|
||||
| `earliestTime` | string | Earliest \(inclusive\) time bound for the search |
|
||||
| `latestTime` | string | Latest \(exclusive\) time bound for the search |
|
||||
| `searchEarliestTime` | string | Earliest time as specified in the search command itself |
|
||||
| `searchLatestTime` | string | Latest time as specified in the search command itself |
|
||||
| `searchEarliestTime` | number | Earliest time as specified in the search command itself, as an epoch timestamp. Unlike earliestTime, which the job entry renders as an ISO string, this pair is documented as bare numbers \(e.g. 1308589800.000000000\). |
|
||||
| `searchLatestTime` | number | Latest time as specified in the search command itself, as an epoch timestamp. Unlike latestTime, which the job entry renders as an ISO string, this pair is documented as bare numbers. |
|
||||
| `messages` | json | Errors and debug messages recorded for the job |
|
||||
|
||||
### Splunk Get Search Results
|
||||
@@ -197,56 +154,13 @@ Fetch the transformed results of a completed Splunk search job by search ID, wit
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `results` | json | Search result rows, each holding the fields the search produced |
|
||||
| `resultCount` | number | Number of result rows returned |
|
||||
| `preview` | boolean | Whether the results are previews |
|
||||
| `initOffset` | number | Offset of the first returned row |
|
||||
| `messages` | json | Messages returned with the response \(\[\{type, text\}\]\) |
|
||||
| `sid` | string | Search ID of the job |
|
||||
| `label` | string | Custom name of the search job |
|
||||
| `dispatchState` | string | Current state of the search job |
|
||||
| `doneProgress` | number | Approximate job progress between 0 and 1 |
|
||||
| `isDone` | boolean | Whether the search has completed |
|
||||
| `isFailed` | boolean | Whether the search failed |
|
||||
| `isFinalized` | boolean | Whether the search was finalized |
|
||||
| `isPaused` | boolean | Whether the search is paused |
|
||||
| `isZombie` | boolean | Whether the search process died |
|
||||
| `isSaved` | boolean | Whether the job artifacts are saved |
|
||||
| `isSavedSearch` | boolean | Whether the job came from a saved search |
|
||||
| `isRealTimeSearch` | boolean | Whether this is a real-time search |
|
||||
| `eventCount` | number | Number of events returned |
|
||||
| `eventAvailableCount` | number | Number of events available for export |
|
||||
| `eventFieldCount` | number | Number of fields found in the results |
|
||||
| `resultPreviewCount` | number | Number of rows in the latest preview |
|
||||
| `scanCount` | number | Number of events scanned off disk |
|
||||
| `runDuration` | number | Seconds the search took to complete |
|
||||
| `priority` | number | Search priority between 0 and 10 |
|
||||
| `earliestTime` | string | Earliest time bound of the job |
|
||||
| `latestTime` | string | Latest time bound of the job |
|
||||
| `searchEarliestTime` | string | Earliest time as specified in the search command |
|
||||
| `searchLatestTime` | string | Latest time as specified in the search command |
|
||||
| `savedSearches` | json | Saved searches \(\[\{name, id, author, updated, search, description, disabled, isScheduled, cronSchedule, alertType\}\]\) |
|
||||
| `name` | string | Saved search name |
|
||||
| `id` | string | Fully qualified REST URI of the resource |
|
||||
| `author` | string | Owner of the saved search |
|
||||
| `updated` | string | Last update timestamp |
|
||||
| `search` | string | SPL the saved search runs |
|
||||
| `qualifiedSearch` | string | Exact search string the scheduler runs |
|
||||
| `description` | string | Saved search description |
|
||||
| `disabled` | boolean | Whether the saved search is disabled |
|
||||
| `isScheduled` | boolean | Whether the search runs on a schedule |
|
||||
| `isVisible` | boolean | Whether the search is listed as visible |
|
||||
| `cronSchedule` | string | Cron schedule for the search |
|
||||
| `nextScheduledTime` | string | Next scheduled run time |
|
||||
| `alertType` | string | Alert condition type |
|
||||
| `dispatchEarliestTime` | string | Earliest time used when dispatching |
|
||||
| `dispatchLatestTime` | string | Latest time used when dispatching |
|
||||
| `alerts` | json | Saved searches with currently triggered alerts \(\[\{name, id, updated, triggeredAlertCount\}\]\) |
|
||||
| `firedAlerts` | json | Triggered instances of an alert \(\[\{name, savedSearchName, alertType, severity, sid, triggerTime\}\]\) |
|
||||
| `indexes` | json | Indexes configured on the instance \(\[\{name, datatype, disabled, totalEventCount, currentDBSizeMB, maxTotalDataSizeMB, minTime, maxTime\}\]\) |
|
||||
| `apps` | json | Apps installed on the instance \(name, label, version, author, disabled\) |
|
||||
| `total` | number | Total number of entries matching a list request, from the response paging envelope. Compare with offset to decide whether another page remains. |
|
||||
| `offset` | number | Offset of the first entry in the returned page, from the paging envelope |
|
||||
| `results` | array | Result rows. Each row holds the fields produced by the search. |
|
||||
| `resultCount` | number | Number of result rows returned in this response |
|
||||
| `preview` | boolean | Whether these are preview results from a still-running job |
|
||||
| `initOffset` | number | Offset of the first returned row within the full result set |
|
||||
| `messages` | array | Search messages returned alongside the results |
|
||||
| ↳ `type` | string | Message severity |
|
||||
| ↳ `text` | string | Message text |
|
||||
|
||||
### Splunk Cancel Search Job
|
||||
|
||||
@@ -269,6 +183,9 @@ Cancel a running Splunk search job and delete its result cache.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `sid` | string | Search ID of the cancelled job |
|
||||
| `messages` | array | Informational, warning, and error messages returned with the response |
|
||||
| ↳ `type` | string | Message severity \(INFO, WARN, ERROR, DEBUG\) |
|
||||
| ↳ `text` | string | Message text |
|
||||
|
||||
### Splunk List Saved Searches
|
||||
|
||||
@@ -308,6 +225,8 @@ List saved searches and reports configured in Splunk, including their SPL, sched
|
||||
| ↳ `alertType` | string | Alert condition type \(e.g. always, custom, number of events\) |
|
||||
| ↳ `dispatchEarliestTime` | string | Earliest time bound used when the search is dispatched |
|
||||
| ↳ `dispatchLatestTime` | string | Latest time bound used when the search is dispatched |
|
||||
| `total` | number | Total number of entries matching the request, from the response paging envelope. Compare with offset to decide whether another page remains. |
|
||||
| `offset` | number | Offset of the first entry in this page, echoed from the response paging envelope |
|
||||
|
||||
### Splunk Get Saved Search
|
||||
|
||||
@@ -400,6 +319,8 @@ List the saved searches with currently triggered (unexpired) Splunk alerts and h
|
||||
| ↳ `id` | string | Fully qualified REST URI of the entry |
|
||||
| ↳ `updated` | string | Last update timestamp |
|
||||
| ↳ `triggeredAlertCount` | number | Trigger count for this alert |
|
||||
| `total` | number | Total number of entries matching the request, from the response paging envelope. Compare with offset to decide whether another page remains. |
|
||||
| `offset` | number | Offset of the first entry in this page, echoed from the response paging envelope |
|
||||
|
||||
### Splunk Get Fired Alerts
|
||||
|
||||
@@ -415,7 +336,7 @@ List the unexpired triggered instances of a Splunk alert by saved search name, i
|
||||
| `password` | string | No | Splunk password, used for basic authentication when no token is supplied |
|
||||
| `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. |
|
||||
| `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. |
|
||||
| `name` | string | Yes | Name of the alerting saved search \(e.g. Errors in the last 24 hours\). Use - to return the fired alerts of every saved search. |
|
||||
| `name` | string | Yes | Name of the alerting saved search \(e.g. Errors in the last 24 hours\). Use - to return the fired alerts of every saved search — this endpoint documents "Request parameters: None", so there is no count or offset to bound that with. Name one saved search unless you really want all of them. |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -473,6 +394,8 @@ List the indexes configured on the Splunk instance with their size, event count,
|
||||
| ↳ `homePath` | string | Path to the hot and warm buckets |
|
||||
| ↳ `coldPath` | string | Path to the cold buckets |
|
||||
| ↳ `thawedPath` | string | Path to the thawed buckets |
|
||||
| `total` | number | Total number of entries matching the request, from the response paging envelope. Compare with offset to decide whether another page remains. |
|
||||
| `offset` | number | Offset of the first entry in this page, echoed from the response paging envelope |
|
||||
|
||||
### Splunk List Apps
|
||||
|
||||
@@ -509,5 +432,7 @@ List the apps installed on the Splunk instance with their label, version, author
|
||||
| ↳ `configured` | boolean | Whether the custom app setup has been completed |
|
||||
| ↳ `checkForUpdates` | boolean | Whether Splunkbase is checked for app updates |
|
||||
| ↳ `stateChangeRequiresRestart` | boolean | Whether changing the app state requires a restart |
|
||||
| `total` | number | Total number of entries matching the request, from the response paging envelope. Compare with offset to decide whether another page remains. |
|
||||
| `offset` | number | Offset of the first entry in this page, echoed from the response paging envelope |
|
||||
|
||||
|
||||
|
||||
@@ -156,6 +156,21 @@ export function getFalconErrorMessage(data: unknown, fallback: string): string {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Raised when the Falcon OAuth2 token exchange fails. Carries the Falcon status
|
||||
* so the route can answer with the real cause (401 for bad credentials) instead
|
||||
* of letting a credential problem fall through to a generic 500.
|
||||
*/
|
||||
export class CrowdStrikeAuthError extends Error {
|
||||
readonly status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'CrowdStrikeAuthError'
|
||||
this.status = status >= 400 && status <= 599 ? status : 502
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAccessToken(params: CrowdStrikeBaseParams): Promise<string> {
|
||||
const baseUrl = getCloudBaseUrl(params.cloud)
|
||||
const response = await fetch(`${baseUrl}/oauth2/token`, {
|
||||
@@ -174,11 +189,14 @@ export async function getAccessToken(params: CrowdStrikeBaseParams): Promise<str
|
||||
|
||||
const data: unknown = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
throw new Error(getFalconErrorMessage(data, 'Failed to authenticate with CrowdStrike'))
|
||||
throw new CrowdStrikeAuthError(
|
||||
getFalconErrorMessage(data, 'Failed to authenticate with CrowdStrike'),
|
||||
response.status
|
||||
)
|
||||
}
|
||||
|
||||
if (!isRecordLike(data) || typeof data.access_token !== 'string') {
|
||||
throw new Error('CrowdStrike authentication did not return an access token')
|
||||
throw new CrowdStrikeAuthError('CrowdStrike authentication did not return an access token', 502)
|
||||
}
|
||||
|
||||
return data.access_token
|
||||
|
||||
@@ -73,6 +73,45 @@ describe('CrowdStrike query route', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces a credential failure with the Falcon status, not a generic 500', async () => {
|
||||
// getAccessToken runs before the operation dispatch, so a Falcon 401 used to
|
||||
// fall through to the catch-all and reach the caller as a 500.
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ errors: [{ code: 401, message: 'access denied, invalid bearer token' }] }, 401)
|
||||
)
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('POST', {
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'wrong-secret',
|
||||
cloud: 'us-1',
|
||||
limit: 1,
|
||||
operation: 'crowdstrike_query_sensors',
|
||||
})
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toEqual({ success: false, error: 'access denied, invalid bearer token' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports an unusable token response as a bad gateway rather than a 500', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ nothing: true }))
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('POST', {
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
cloud: 'us-1',
|
||||
limit: 1,
|
||||
operation: 'crowdstrike_query_sensors',
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(502)
|
||||
})
|
||||
|
||||
it('hydrates sensor details after querying sensor ids', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ access_token: 'token-123' }))
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
CrowdStrikeAuthError,
|
||||
type CrowdStrikeCallResult,
|
||||
callCrowdStrike,
|
||||
getAccessToken,
|
||||
@@ -314,6 +315,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
return NextResponse.json({ success: true, output: result.output })
|
||||
} catch (error) {
|
||||
const message = toError(error).message
|
||||
|
||||
/**
|
||||
* The token exchange runs before the operation dispatch, so without this a
|
||||
* bad client ID or secret (Falcon 401) reaches the caller as a 500.
|
||||
*/
|
||||
if (error instanceof CrowdStrikeAuthError) {
|
||||
logger.warn('CrowdStrike authentication failed', { error: message, status: error.status })
|
||||
return NextResponse.json({ success: false, error: message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error('CrowdStrike request failed', { error: message })
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import { mssqlDeleteContract } from '@/lib/api/contracts/tools/databases/mssql'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { buildDeleteQuery, createMSSQLConnection, executeQuery } from '@/app/api/tools/mssql/utils'
|
||||
import {
|
||||
buildDeleteQuery,
|
||||
createMSSQLConnection,
|
||||
executeQuery,
|
||||
toRowsResponseBody,
|
||||
} from '@/app/api/tools/mssql/utils'
|
||||
|
||||
const logger = createLogger('MSSQLDeleteAPI')
|
||||
|
||||
@@ -28,19 +33,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
`[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
/**
|
||||
* Built before connecting so a rejected WHERE clause or a bad identifier
|
||||
* costs no TLS+login round trip and answers 400 like the query and execute
|
||||
* routes, rather than falling through to the catch-all as a 500.
|
||||
*/
|
||||
let built: { query: string; values: unknown[] }
|
||||
try {
|
||||
built = buildDeleteQuery(params.table, params.where)
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Invalid statement')
|
||||
logger.warn(`[${requestId}] Delete statement rejected: ${message}`)
|
||||
return NextResponse.json(
|
||||
{ error: `Microsoft SQL Server delete failed: ${message}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const pool = await createMSSQLConnection(params)
|
||||
|
||||
try {
|
||||
const { query, values } = buildDeleteQuery(params.table, params.where)
|
||||
const result = await executeQuery(pool, query, values)
|
||||
const result = await executeQuery(pool, built.query, built.values)
|
||||
|
||||
logger.info(`[${requestId}] Delete executed successfully, ${result.rowCount} row(s) deleted`)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Data deleted successfully. ${result.rowCount} row(s) affected.`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
return NextResponse.json(
|
||||
toRowsResponseBody(result, `Data deleted successfully. ${result.rowCount} row(s) affected.`)
|
||||
)
|
||||
} finally {
|
||||
await pool.close()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import { mssqlExecuteContract } from '@/lib/api/contracts/tools/databases/mssql'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createMSSQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mssql/utils'
|
||||
import {
|
||||
createMSSQLConnection,
|
||||
executeQuery,
|
||||
toRowsResponseBody,
|
||||
validateQuery,
|
||||
} from '@/app/api/tools/mssql/utils'
|
||||
|
||||
const logger = createLogger('MSSQLExecuteAPI')
|
||||
|
||||
@@ -44,11 +49,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
|
||||
logger.info(`[${requestId}] T-SQL executed successfully, ${result.rowCount} row(s) affected`)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `SQL executed successfully. ${result.rowCount} row(s) affected.`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
return NextResponse.json(
|
||||
toRowsResponseBody(result, `SQL executed successfully. ${result.rowCount} row(s) affected.`)
|
||||
)
|
||||
} finally {
|
||||
await pool.close()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import { mssqlInsertContract } from '@/lib/api/contracts/tools/databases/mssql'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { buildInsertQuery, createMSSQLConnection, executeQuery } from '@/app/api/tools/mssql/utils'
|
||||
import {
|
||||
buildInsertQuery,
|
||||
createMSSQLConnection,
|
||||
executeQuery,
|
||||
toRowsResponseBody,
|
||||
} from '@/app/api/tools/mssql/utils'
|
||||
|
||||
const logger = createLogger('MSSQLInsertAPI')
|
||||
|
||||
@@ -28,19 +33,36 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
`[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
/**
|
||||
* Built before connecting so a bad identifier costs no TLS+login round trip
|
||||
* and answers 400 like the update, delete, query, and execute routes, rather
|
||||
* than falling through to the catch-all as a 500.
|
||||
*/
|
||||
let built: { query: string; values: unknown[] }
|
||||
try {
|
||||
built = buildInsertQuery(params.table, params.data)
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Invalid statement')
|
||||
logger.warn(`[${requestId}] Insert statement rejected: ${message}`)
|
||||
return NextResponse.json(
|
||||
{ error: `Microsoft SQL Server insert failed: ${message}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const pool = await createMSSQLConnection(params)
|
||||
|
||||
try {
|
||||
const { query, values } = buildInsertQuery(params.table, params.data)
|
||||
const result = await executeQuery(pool, query, values)
|
||||
const result = await executeQuery(pool, built.query, built.values)
|
||||
|
||||
logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Data inserted successfully. ${result.rowCount} row(s) affected.`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
return NextResponse.json(
|
||||
toRowsResponseBody(
|
||||
result,
|
||||
`Data inserted successfully. ${result.rowCount} row(s) affected.`
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
await pool.close()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
createMSSQLConnection,
|
||||
executeQuery,
|
||||
toRowsResponseBody,
|
||||
validateReadOnlyQuery,
|
||||
} from '@/app/api/tools/mssql/utils'
|
||||
|
||||
@@ -48,11 +49,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
|
||||
logger.info(`[${requestId}] Query executed successfully, returned ${result.rowCount} rows`)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Query executed successfully. ${result.rowCount} row(s) returned.`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
return NextResponse.json(
|
||||
toRowsResponseBody(
|
||||
result,
|
||||
`Query executed successfully. ${result.rowCount} row(s) returned.`
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
await pool.close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* The insert, update, and delete routes build their statement before opening a
|
||||
* connection, so a rejected WHERE clause or a bad identifier costs no TLS+login
|
||||
* round trip and answers 400 like the query and execute routes do.
|
||||
*/
|
||||
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockResolveHostAddresses, mockConnectionPool, mockQuery } = vi.hoisted(() => {
|
||||
const query = vi.fn().mockResolvedValue({ recordset: [], rowsAffected: [1] })
|
||||
const pool = vi.fn(function ConnectionPool(this: Record<string, unknown>) {
|
||||
this.connect = vi.fn().mockResolvedValue(undefined)
|
||||
this.close = vi.fn().mockResolvedValue(undefined)
|
||||
this.request = () => ({ input: vi.fn(), query })
|
||||
})
|
||||
return { mockResolveHostAddresses: vi.fn(), mockConnectionPool: pool, mockQuery: query }
|
||||
})
|
||||
|
||||
vi.mock('mssql', () => ({
|
||||
default: { ConnectionPool: mockConnectionPool },
|
||||
ConnectionPool: mockConnectionPool,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/security/dns', () => ({
|
||||
resolveHostAddresses: mockResolveHostAddresses,
|
||||
preferIpv4: (addresses: string[]) => addresses[0],
|
||||
}))
|
||||
|
||||
import { POST as DELETE_POST } from '@/app/api/tools/mssql/delete/route'
|
||||
import { POST as INSERT_POST } from '@/app/api/tools/mssql/insert/route'
|
||||
import { POST as UPDATE_POST } from '@/app/api/tools/mssql/update/route'
|
||||
|
||||
const connection = {
|
||||
host: 'db.example.com',
|
||||
port: 1433,
|
||||
database: 'app',
|
||||
username: 'app',
|
||||
password: 'secret',
|
||||
encrypt: 'enabled',
|
||||
trustServerCertificate: 'disabled',
|
||||
connectionTimeout: 15000,
|
||||
}
|
||||
|
||||
describe('MSSQL insert, update, and delete guards run before connecting', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'internal_jwt',
|
||||
})
|
||||
mockResolveHostAddresses.mockResolvedValue({ addresses: ['93.184.216.34'], isPrivate: false })
|
||||
mockQuery.mockResolvedValue({ recordset: [], rowsAffected: [1] })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['update', UPDATE_POST, { table: 'users', data: { a: 1 }, where: 'id = 1 OR 1=1' }],
|
||||
['delete', DELETE_POST, { table: 'users', where: 'id = 1 OR 1=1' }],
|
||||
])(
|
||||
'answers 400 for a rejected WHERE clause on %s without connecting',
|
||||
async (_op, handler, body) => {
|
||||
const response = await handler(createMockRequest('POST', { ...connection, ...body }))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockConnectionPool).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['insert', INSERT_POST, { table: 'users-table', data: { a: 1 } }],
|
||||
['insert column', INSERT_POST, { table: 'users', data: { 'bad-col': 1 } }],
|
||||
['update', UPDATE_POST, { table: 'users-table', data: { a: 1 }, where: 'id = 1' }],
|
||||
['delete', DELETE_POST, { table: 'users-table', where: 'id = 1' }],
|
||||
])('answers 400 for a bad identifier on %s without connecting', async (_op, handler, body) => {
|
||||
const response = await handler(createMockRequest('POST', { ...connection, ...body }))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockConnectionPool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['insert', INSERT_POST, { table: 'users', data: { a: 1 } }],
|
||||
['update', UPDATE_POST, { table: 'users', data: { a: 1 }, where: 'id = 1' }],
|
||||
['delete', DELETE_POST, { table: 'users', where: 'id = 1' }],
|
||||
])('still runs an accepted %s statement', async (_op, handler, body) => {
|
||||
const response = await handler(createMockRequest('POST', { ...connection, ...body }))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockConnectionPool).toHaveBeenCalledTimes(1)
|
||||
expect(mockQuery).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,12 @@ import { mssqlUpdateContract } from '@/lib/api/contracts/tools/databases/mssql'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { buildUpdateQuery, createMSSQLConnection, executeQuery } from '@/app/api/tools/mssql/utils'
|
||||
import {
|
||||
buildUpdateQuery,
|
||||
createMSSQLConnection,
|
||||
executeQuery,
|
||||
toRowsResponseBody,
|
||||
} from '@/app/api/tools/mssql/utils'
|
||||
|
||||
const logger = createLogger('MSSQLUpdateAPI')
|
||||
|
||||
@@ -28,19 +33,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
`[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
/**
|
||||
* Built before connecting so a rejected WHERE clause or a bad identifier
|
||||
* costs no TLS+login round trip and answers 400 like the query and execute
|
||||
* routes, rather than falling through to the catch-all as a 500.
|
||||
*/
|
||||
let built: { query: string; values: unknown[] }
|
||||
try {
|
||||
built = buildUpdateQuery(params.table, params.data, params.where)
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Invalid statement')
|
||||
logger.warn(`[${requestId}] Update statement rejected: ${message}`)
|
||||
return NextResponse.json(
|
||||
{ error: `Microsoft SQL Server update failed: ${message}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const pool = await createMSSQLConnection(params)
|
||||
|
||||
try {
|
||||
const { query, values } = buildUpdateQuery(params.table, params.data, params.where)
|
||||
const result = await executeQuery(pool, query, values)
|
||||
const result = await executeQuery(pool, built.query, built.values)
|
||||
|
||||
logger.info(`[${requestId}] Update executed successfully, ${result.rowCount} row(s) updated`)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Data updated successfully. ${result.rowCount} row(s) affected.`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
return NextResponse.json(
|
||||
toRowsResponseBody(result, `Data updated successfully. ${result.rowCount} row(s) affected.`)
|
||||
)
|
||||
} finally {
|
||||
await pool.close()
|
||||
}
|
||||
|
||||
@@ -38,8 +38,10 @@ import {
|
||||
buildInsertQuery,
|
||||
buildUpdateQuery,
|
||||
createMSSQLConnection,
|
||||
executeIntrospect,
|
||||
executeQuery,
|
||||
type MSSQLConnectionConfig,
|
||||
toRowsResponseBody,
|
||||
validateQuery,
|
||||
validateReadOnlyQuery,
|
||||
} from '@/app/api/tools/mssql/utils'
|
||||
@@ -388,3 +390,327 @@ describe('createMSSQLConnection DNS pinning', () => {
|
||||
expect(config.options.trustServerCertificate).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('read-only screens cover the rest of the session and transaction family', () => {
|
||||
/**
|
||||
* Each is a valid semicolon-less second statement, and the file's stated rule
|
||||
* is that a second statement is rejected structurally rather than by what it
|
||||
* happens to do.
|
||||
*/
|
||||
it.each([
|
||||
['SAVE TRANSACTION', 'SELECT 1 SAVE TRANSACTION sp1'],
|
||||
['SAVE TRAN', 'SELECT 1 SAVE TRAN sp1'],
|
||||
['OPEN SYMMETRIC KEY', 'SELECT 1 OPEN SYMMETRIC KEY k DECRYPTION BY CERTIFICATE c'],
|
||||
['OPEN MASTER KEY', "SELECT 1 OPEN MASTER KEY DECRYPTION BY PASSWORD = 'p'"],
|
||||
['CLOSE ALL SYMMETRIC KEYS', 'SELECT 1 CLOSE ALL SYMMETRIC KEYS'],
|
||||
['CLOSE MASTER KEY', 'SELECT 1 CLOSE MASTER KEY'],
|
||||
['DEALLOCATE', 'SELECT 1 DEALLOCATE cur'],
|
||||
['ADD SIGNATURE', 'SELECT 1 ADD SIGNATURE TO dbo.p BY CERTIFICATE c'],
|
||||
['RAISERROR WITH LOG', "SELECT 1 RAISERROR ('boom', 16, 1) WITH LOG"],
|
||||
])('rejects %s in the Query operation', (_label, query) => {
|
||||
expect(validateReadOnlyQuery(query).isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['SAVE TRANSACTION', 'id = 1 SAVE TRANSACTION sp1'],
|
||||
['OPEN SYMMETRIC KEY', 'id = 1 OPEN SYMMETRIC KEY k DECRYPTION BY CERTIFICATE c'],
|
||||
['CLOSE ALL SYMMETRIC KEYS', 'id = 1 CLOSE ALL SYMMETRIC KEYS'],
|
||||
['DEALLOCATE', 'id = 1 DEALLOCATE cur'],
|
||||
['ADD SIGNATURE', 'id = 1 ADD SIGNATURE TO dbo.p BY CERTIFICATE c'],
|
||||
['RAISERROR WITH LOG', "id = 1 RAISERROR ('boom', 16, 1) WITH LOG"],
|
||||
])('rejects %s in an update or delete WHERE clause', (_label, where) => {
|
||||
expect(() => buildUpdateQuery('t', { a: 1 }, where)).toThrow()
|
||||
expect(() => buildDeleteQuery('t', where)).toThrow()
|
||||
})
|
||||
|
||||
/**
|
||||
* The over-screening guard. `open`, `close`, `save`, and `add` are ordinary
|
||||
* column names (a price table has all four), so a bare-word screen would make
|
||||
* the plain SELECTs this operation exists to run un-runnable.
|
||||
*/
|
||||
it('still accepts ordinary identifiers that start with a screened phrase word', () => {
|
||||
const allowed = [
|
||||
'SELECT open, close, high, low FROM dbo.prices',
|
||||
'SELECT close FROM dbo.prices WHERE open > 10',
|
||||
'SELECT save_id, add_on, open_date, close_date FROM dbo.orders',
|
||||
'SELECT o.open, o.close FROM dbo.ohlc o ORDER BY o.open DESC',
|
||||
]
|
||||
|
||||
for (const query of allowed) {
|
||||
expect(validateReadOnlyQuery(query)).toEqual({ isValid: true })
|
||||
}
|
||||
|
||||
expect(() => buildUpdateQuery('prices', { close: 2 }, 'open > 10')).not.toThrow()
|
||||
expect(() => buildDeleteQuery('prices', 'close < 1 AND open_date > 0')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('read-only screens cover RENAME and the Service Broker statement family', () => {
|
||||
/**
|
||||
* `RENAME` is documented T-SQL DDL for Azure Synapse dedicated SQL pools and
|
||||
* Analytics Platform System, both reachable over TDS with the connection
|
||||
* fields this block exposes — so a schema change was passing an operation
|
||||
* advertised as read-only.
|
||||
*/
|
||||
it.each([
|
||||
['RENAME OBJECT', 'SELECT 1 RENAME OBJECT dbo.Customer TO Customer1'],
|
||||
['RENAME OBJECT COLUMN', 'SELECT 1 RENAME OBJECT dbo.t COLUMN c1 TO c2'],
|
||||
['RENAME DATABASE', 'SELECT 1 RENAME DATABASE db1 TO db2'],
|
||||
['RECEIVE', 'SELECT 1 RECEIVE TOP(1) * FROM dbo.MyQueue'],
|
||||
['END CONVERSATION', "SELECT 1 END CONVERSATION '00000000-0000-0000-0000-000000000000'"],
|
||||
[
|
||||
'MOVE CONVERSATION',
|
||||
"SELECT 1 MOVE CONVERSATION '00000000-0000-0000-0000-000000000000' TO '00000000-0000-0000-0000-000000000001'",
|
||||
],
|
||||
['GET CONVERSATION GROUP', 'SELECT 1 GET CONVERSATION GROUP @g FROM dbo.MyQueue'],
|
||||
[
|
||||
'SEND ON CONVERSATION',
|
||||
"SELECT 1 SEND ON CONVERSATION '00000000-0000-0000-0000-000000000000' MESSAGE TYPE [t] ('x')",
|
||||
],
|
||||
])('rejects %s in the Query operation', (_label, query) => {
|
||||
expect(validateReadOnlyQuery(query).isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['RENAME OBJECT', 'id = 1 RENAME OBJECT dbo.t TO t2'],
|
||||
['RECEIVE', 'id = 1 RECEIVE TOP(1) * FROM dbo.MyQueue'],
|
||||
['END CONVERSATION', "id = 1 END CONVERSATION '00000000-0000-0000-0000-000000000000'"],
|
||||
['GET CONVERSATION GROUP', 'id = 1 GET CONVERSATION GROUP @g FROM dbo.MyQueue'],
|
||||
])('rejects %s in an update or delete WHERE clause', (_label, where) => {
|
||||
expect(() => buildUpdateQuery('t', { a: 1 }, where)).toThrow()
|
||||
expect(() => buildDeleteQuery('t', where)).toThrow()
|
||||
})
|
||||
|
||||
/**
|
||||
* The over-screening guard. `END` closes every `CASE`, and `rename`/`receive`
|
||||
* are the stems of ordinary column names, so neither addition may cost the
|
||||
* plain SELECTs this operation exists to run.
|
||||
*/
|
||||
it('still accepts CASE … END and ordinary identifiers built on the new words', () => {
|
||||
const allowed = [
|
||||
"SELECT CASE WHEN status = 1 THEN 'on' ELSE 'off' END FROM dbo.jobs",
|
||||
"SELECT CASE WHEN a = 1 THEN 'x' END AS conversation_state FROM dbo.t",
|
||||
'SELECT renamed_at, rename_log, received_at, receive_queue FROM dbo.audit',
|
||||
'SELECT conversation_id, get_flag, move_order, send_at, end_date FROM dbo.t',
|
||||
]
|
||||
|
||||
for (const query of allowed) {
|
||||
expect(validateReadOnlyQuery(query)).toEqual({ isValid: true })
|
||||
}
|
||||
|
||||
expect(() => buildUpdateQuery('audit', { a: 1 }, 'renamed_at > 0')).not.toThrow()
|
||||
expect(() => buildDeleteQuery('audit', 'received_at > 0 AND conversation_id = 3')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeQuery result caps', () => {
|
||||
function makeCapPool(recordset: unknown[]) {
|
||||
return {
|
||||
request: () => ({
|
||||
input: vi.fn(),
|
||||
query: vi.fn().mockResolvedValue({ recordset, rowsAffected: [0] }),
|
||||
}),
|
||||
} as never
|
||||
}
|
||||
|
||||
it('caps the recordset at the row ceiling and says so', async () => {
|
||||
const result = await executeQuery(
|
||||
makeCapPool(Array.from({ length: 10_001 }, (_, i) => ({ i }))),
|
||||
'SELECT 1'
|
||||
)
|
||||
|
||||
expect(result.rows).toHaveLength(10_000)
|
||||
expect(result.rowCount).toBe(10_000)
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.truncationReason).toMatch(/OFFSET/)
|
||||
})
|
||||
|
||||
it('caps on bytes even when the row count is small', async () => {
|
||||
// 20 rows of ~1MB each: well under the row ceiling, well over the byte one.
|
||||
const fat = Array.from({ length: 20 }, () => ({ blob: 'x'.repeat(1024 * 1024) }))
|
||||
const result = await executeQuery(makeCapPool(fat), 'SELECT 1')
|
||||
|
||||
expect(result.rows.length).toBeLessThan(20)
|
||||
expect(result.truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves an ordinary result untouched', async () => {
|
||||
const rows = [{ id: 1 }, { id: 2 }]
|
||||
const result = await executeQuery(makeCapPool(rows), 'SELECT 1')
|
||||
|
||||
expect(result.rows).toEqual(rows)
|
||||
expect(result.truncated).toBeUndefined()
|
||||
expect(result.truncationReason).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never serializes past the byte ceiling', async () => {
|
||||
const fat = Array.from({ length: 20 }, () => ({ blob: 'x'.repeat(1024 * 1024) }))
|
||||
const result = await executeQuery(makeCapPool(fat), 'SELECT 1')
|
||||
|
||||
expect(JSON.stringify(result.rows).length).toBeLessThanOrEqual(10 * 1024 * 1024)
|
||||
})
|
||||
|
||||
it('drops a lone row that is larger than the byte ceiling rather than admitting it', async () => {
|
||||
const oversized = [{ blob: 'x'.repeat(11 * 1024 * 1024) }]
|
||||
const result = await executeQuery(makeCapPool(oversized), 'SELECT 1')
|
||||
|
||||
expect(result.rows).toEqual([])
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.truncationReason).toMatch(/exceeds the 10 MB response ceiling/)
|
||||
})
|
||||
|
||||
/**
|
||||
* `String.length` counts UTF-16 code units and the response is emitted as
|
||||
* UTF-8, so a CJK recordset costs three bytes for every unit the old
|
||||
* accounting charged one for. Measured with `length` these rows fit; measured
|
||||
* as the bytes that actually go on the wire they are ~3x over.
|
||||
*/
|
||||
it('bounds a multibyte recordset by UTF-8 bytes, not UTF-16 code units', async () => {
|
||||
const cjk = Array.from({ length: 20 }, () => ({ blob: '世'.repeat(1024 * 1024) }))
|
||||
const result = await executeQuery(makeCapPool(cjk), 'SELECT 1')
|
||||
|
||||
expect(Buffer.byteLength(JSON.stringify(result.rows), 'utf8')).toBeLessThanOrEqual(
|
||||
10 * 1024 * 1024
|
||||
)
|
||||
expect(result.rows.length).toBeGreaterThan(0)
|
||||
expect(result.truncated).toBe(true)
|
||||
})
|
||||
|
||||
/** Emoji are 4 UTF-8 bytes across 2 surrogate code units — a 2:1 undercount. */
|
||||
it('bounds an astral-plane recordset by UTF-8 bytes', async () => {
|
||||
const emoji = Array.from({ length: 20 }, () => ({ blob: '😀'.repeat(1024 * 1024) }))
|
||||
const result = await executeQuery(makeCapPool(emoji), 'SELECT 1')
|
||||
|
||||
expect(Buffer.byteLength(JSON.stringify(result.rows), 'utf8')).toBeLessThanOrEqual(
|
||||
10 * 1024 * 1024
|
||||
)
|
||||
expect(result.truncated).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* Rows sized to divide the ceiling exactly, so an accounting that ignores the
|
||||
* array's commas and the fields around it lands precisely on the limit and the
|
||||
* body it emits is over by the punctuation and the envelope.
|
||||
*/
|
||||
it('keeps the emitted body inside the ceiling once array and envelope overhead is counted', async () => {
|
||||
const rowPayload = 'x'.repeat(2048 - '{"blob":""}'.length)
|
||||
const packed = Array.from({ length: 6000 }, () => ({ blob: rowPayload }))
|
||||
const result = await executeQuery(makeCapPool(packed), 'SELECT 1')
|
||||
|
||||
const body = toRowsResponseBody(result, 'Query executed successfully. rows returned.')
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(Buffer.byteLength(JSON.stringify(body), 'utf8')).toBeLessThanOrEqual(10 * 1024 * 1024)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toRowsResponseBody truncation disclosure', () => {
|
||||
it('discloses a truncated result in both the message and machine-readable fields', () => {
|
||||
const body = toRowsResponseBody(
|
||||
{
|
||||
rows: [{ id: 1 }],
|
||||
rowCount: 1,
|
||||
truncated: true,
|
||||
truncationReason: 'Result truncated to 1 row(s): page with OFFSET ... FETCH NEXT.',
|
||||
},
|
||||
'Query executed successfully. 1 row(s) returned.'
|
||||
)
|
||||
|
||||
expect(body.truncated).toBe(true)
|
||||
expect(body.truncationReason).toMatch(/OFFSET/)
|
||||
expect(body.message).toBe(
|
||||
'Query executed successfully. 1 row(s) returned. Result truncated to 1 row(s): page with OFFSET ... FETCH NEXT.'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a complete result free of truncation fields', () => {
|
||||
const body = toRowsResponseBody(
|
||||
{ rows: [{ id: 1 }], rowCount: 1 },
|
||||
'Query executed successfully. 1 row(s) returned.'
|
||||
)
|
||||
|
||||
expect(body.message).toBe('Query executed successfully. 1 row(s) returned.')
|
||||
expect(body).not.toHaveProperty('truncated')
|
||||
expect(body).not.toHaveProperty('truncationReason')
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeIntrospect issues a fixed number of queries', () => {
|
||||
const schemas = [{ SCHEMA_NAME: 'dbo' }]
|
||||
const introspectTables = Array.from({ length: 50 }, (_, i) => ({
|
||||
TABLE_NAME: `t${i}`,
|
||||
TABLE_SCHEMA: 'dbo',
|
||||
}))
|
||||
const introspectColumns = introspectTables.flatMap((t) => [
|
||||
{
|
||||
TABLE_NAME: t.TABLE_NAME,
|
||||
COLUMN_NAME: 'id',
|
||||
DATA_TYPE: 'int',
|
||||
IS_NULLABLE: 'NO',
|
||||
COLUMN_DEFAULT: null,
|
||||
},
|
||||
{
|
||||
TABLE_NAME: t.TABLE_NAME,
|
||||
COLUMN_NAME: 'owner_id',
|
||||
DATA_TYPE: 'int',
|
||||
IS_NULLABLE: 'YES',
|
||||
COLUMN_DEFAULT: null,
|
||||
},
|
||||
])
|
||||
const introspectPks = introspectTables.map((t) => ({
|
||||
TABLE_NAME: t.TABLE_NAME,
|
||||
COLUMN_NAME: 'id',
|
||||
}))
|
||||
const introspectFks = introspectTables.map((t) => ({
|
||||
TABLE_NAME: t.TABLE_NAME,
|
||||
COLUMN_NAME: 'owner_id',
|
||||
REFERENCED_TABLE_SCHEMA: 'dbo',
|
||||
REFERENCED_TABLE_NAME: 'owners',
|
||||
REFERENCED_COLUMN_NAME: 'id',
|
||||
}))
|
||||
const introspectIndexes = introspectTables.map((t) => ({
|
||||
TABLE_NAME: t.TABLE_NAME,
|
||||
INDEX_NAME: `ix_${t.TABLE_NAME}_owner`,
|
||||
COLUMN_NAME: 'owner_id',
|
||||
IS_UNIQUE: 0,
|
||||
}))
|
||||
|
||||
function makeIntrospectPool() {
|
||||
const query = vi.fn(async (text: string) => {
|
||||
if (text.includes('FROM sys.schemas s')) return { recordset: schemas }
|
||||
if (text.includes('INFORMATION_SCHEMA.TABLES')) return { recordset: introspectTables }
|
||||
if (text.includes('INFORMATION_SCHEMA.COLUMNS')) return { recordset: introspectColumns }
|
||||
if (text.includes('PRIMARY KEY')) return { recordset: introspectPks }
|
||||
if (text.includes('sys.foreign_keys')) return { recordset: introspectFks }
|
||||
if (text.includes('sys.index_columns')) return { recordset: introspectIndexes }
|
||||
throw new Error(`unexpected query: ${text}`)
|
||||
})
|
||||
return { pool: { request: () => ({ input: vi.fn().mockReturnThis(), query }) } as never, query }
|
||||
}
|
||||
|
||||
it('does not scale its round trips with the table count', async () => {
|
||||
// Previously 4 queries per table plus 2: 50 tables meant 202 sequential
|
||||
// round trips, each under its own request timeout.
|
||||
const { pool, query } = makeIntrospectPool()
|
||||
|
||||
const result = await executeIntrospect(pool, 'dbo')
|
||||
|
||||
expect(result.tables).toHaveLength(50)
|
||||
expect(query.mock.calls.length).toBeLessThanOrEqual(6)
|
||||
})
|
||||
|
||||
it('still attributes columns, keys, and indexes to the right table', async () => {
|
||||
const { pool } = makeIntrospectPool()
|
||||
|
||||
const result = await executeIntrospect(pool, 'dbo')
|
||||
const table = result.tables.find((t) => t.name === 't7')!
|
||||
|
||||
expect(table.schema).toBe('dbo')
|
||||
expect(table.columns.map((c) => c.name)).toEqual(['id', 'owner_id'])
|
||||
expect(table.primaryKey).toEqual(['id'])
|
||||
expect(table.columns[0].isPrimaryKey).toBe(true)
|
||||
expect(table.columns[1].isForeignKey).toBe(true)
|
||||
expect(table.columns[1].references).toEqual({ schema: 'dbo', table: 'owners', column: 'id' })
|
||||
expect(table.indexes).toEqual([{ name: 'ix_t7_owner', columns: ['owner_id'], unique: false }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -124,6 +124,10 @@ export async function createMSSQLConnection(
|
||||
export interface MSSQLQueryResult {
|
||||
rows: unknown[]
|
||||
rowCount: number
|
||||
/** Set when the recordset hit a row or byte ceiling and rows were dropped. */
|
||||
truncated?: boolean
|
||||
/** Human-readable explanation of the ceiling that was hit. */
|
||||
truncationReason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,6 +149,87 @@ function toBindableValue(value: unknown): unknown {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ceilings on what a single statement may materialize into the response.
|
||||
*
|
||||
* The driver buffers the whole recordset before `request.query` resolves, and
|
||||
* the route then serializes it into a JSON body, so an unbounded `SELECT` over a
|
||||
* large table is held in memory twice. A caller who wants more pages it with
|
||||
* `OFFSET ... FETCH NEXT`. The byte ceiling exists because row count alone does
|
||||
* not bound size — 1,000 rows of `nvarchar(max)` is not a small result.
|
||||
*/
|
||||
const MSSQL_MAX_RESULT_ROWS = 10_000
|
||||
const MSSQL_MAX_RESULT_BYTES = 10 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Bytes held back from {@link MSSQL_MAX_RESULT_BYTES} for the part of the
|
||||
* response body that is not a row.
|
||||
*
|
||||
* {@link toRowsResponseBody} wraps `rows` in `message`, `rowCount`, and — when
|
||||
* the recordset was capped — `truncated` and `truncationReason`, none of which
|
||||
* the per-row accounting can see. Those are a few hundred bytes at their
|
||||
* longest (the truncation prose is the bulk of it), so the reserve is set an
|
||||
* order of magnitude above the worst case and costs 0.04% of the ceiling. The
|
||||
* alternative, serializing the assembled body to check it, would re-serialize
|
||||
* the whole recordset a second time for no useful precision.
|
||||
*/
|
||||
const MSSQL_RESPONSE_ENVELOPE_BYTES = 4096
|
||||
|
||||
/** What the serialized `rows` array itself may occupy. */
|
||||
const MSSQL_MAX_ROWS_BYTES = MSSQL_MAX_RESULT_BYTES - MSSQL_RESPONSE_ENVELOPE_BYTES
|
||||
|
||||
/**
|
||||
* Truncates a recordset to the row and byte ceilings.
|
||||
*
|
||||
* Measures each row with `JSON.stringify` because that is what the route will do
|
||||
* anyway, so the number bounds the response the caller actually receives rather
|
||||
* than an in-memory estimate that does not correspond to it. Each row is
|
||||
* serialized exactly once and its cost accumulated, rather than re-serializing
|
||||
* the growing array per row, which would be quadratic on a large recordset.
|
||||
*
|
||||
* The size is `Buffer.byteLength(..., 'utf8')`, not `String.length`. `length`
|
||||
* counts UTF-16 code units while `NextResponse.json` emits UTF-8, and every
|
||||
* character above U+007F costs more bytes than code units — worst case 3:1, for
|
||||
* the U+0800–U+FFFF range that holds CJK, so a recordset of Chinese text passed
|
||||
* a 10 MB `length` budget while serializing to nearly 30 MB. (Astral characters
|
||||
* such as emoji are only 2:1: 4 bytes across 2 surrogate code units.)
|
||||
*
|
||||
* The array's own punctuation is counted too — one byte per row covers the
|
||||
* opening `[` for the first row and the separating `,` for each one after it,
|
||||
* with the leading byte standing in for the closing `]` — and
|
||||
* {@link MSSQL_RESPONSE_ENVELOPE_BYTES} covers the fields around it. Without
|
||||
* both, a result packed exactly to the ceiling still emitted a body over it.
|
||||
*
|
||||
* A row is admitted only when it still fits, so a single row larger than the
|
||||
* byte ceiling is dropped rather than admitted as a lone exception — otherwise
|
||||
* `SELECT` of one `nvarchar(max)` value would serialize an unbounded body and
|
||||
* the ceiling would bound everything except the case it exists for. The drop is
|
||||
* disclosed through {@link MSSQLQueryResult.truncationReason}, so an empty
|
||||
* recordset is never mistaken for an empty table.
|
||||
*/
|
||||
function capRecordset(rows: unknown[]): { rows: unknown[]; truncated: boolean } {
|
||||
if (rows.length === 0) return { rows, truncated: false }
|
||||
|
||||
const capped: unknown[] = []
|
||||
/** The closing `]`; each row below pays for its own `[` or `,`. */
|
||||
let bytes = 1
|
||||
|
||||
for (const row of rows) {
|
||||
if (capped.length >= MSSQL_MAX_RESULT_ROWS) break
|
||||
const serialized = JSON.stringify(row)
|
||||
/**
|
||||
* `JSON.stringify` answers `undefined` for a value it cannot represent, but
|
||||
* an array element in that position serializes as the four bytes of `null`.
|
||||
*/
|
||||
const rowBytes = serialized === undefined ? 4 : Buffer.byteLength(serialized, 'utf8')
|
||||
if (bytes + rowBytes + 1 > MSSQL_MAX_ROWS_BYTES) break
|
||||
bytes += rowBytes + 1
|
||||
capped.push(row)
|
||||
}
|
||||
|
||||
return { rows: capped, truncated: capped.length < rows.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a statement with positional values bound as `@param1`, `@param2`, … .
|
||||
*
|
||||
@@ -164,7 +249,7 @@ export async function executeQuery(
|
||||
})
|
||||
|
||||
const result = await request.query(query)
|
||||
const rows: unknown[] = result.recordset ?? []
|
||||
const { rows, truncated } = capRecordset(result.recordset ?? [])
|
||||
const affected = (result.rowsAffected ?? []).reduce(
|
||||
(total: number, count: number) => total + count,
|
||||
0
|
||||
@@ -173,6 +258,34 @@ export async function executeQuery(
|
||||
return {
|
||||
rows,
|
||||
rowCount: rows.length > 0 ? rows.length : affected,
|
||||
...(truncated && {
|
||||
truncated: true,
|
||||
truncationReason:
|
||||
rows.length === 0
|
||||
? `No rows returned: the first row alone exceeds the ${MSSQL_MAX_RESULT_BYTES / (1024 * 1024)} MB response ceiling. Select fewer columns, or slice large values with SUBSTRING.`
|
||||
: `Result truncated to ${rows.length} row(s): a single statement returns at most ${MSSQL_MAX_RESULT_ROWS} rows or ${MSSQL_MAX_RESULT_BYTES / (1024 * 1024)} MB. Page with OFFSET ... FETCH NEXT to read the rest.`,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the success body every statement route returns.
|
||||
*
|
||||
* A truncated recordset is disclosed twice on purpose: folded into `message`, so
|
||||
* an agent that reads only the status line still learns rows were dropped, and
|
||||
* as `truncated`/`truncationReason`, so a caller can branch on it without
|
||||
* parsing prose. Without this the route reported a capped result as a complete
|
||||
* one and paging looked unnecessary.
|
||||
*/
|
||||
export function toRowsResponseBody(result: MSSQLQueryResult, message: string) {
|
||||
return {
|
||||
message: result.truncationReason ? `${message} ${result.truncationReason}` : message,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
...(result.truncated && {
|
||||
truncated: true,
|
||||
truncationReason: result.truncationReason,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +300,11 @@ export async function executeQuery(
|
||||
* `DISABLE`/`ENABLE` are here because `SELECT 1 DISABLE TRIGGER dbo.audit ON
|
||||
* dbo.users` is a valid semicolon-less batch that turns auditing off, and
|
||||
* `SET`/`BEGIN`/`COMMIT`/`ROLLBACK` because session and transaction state are
|
||||
* changed the same way (`SET IDENTITY_INSERT`, `SET ANSI_NULLS`).
|
||||
* changed the same way (`SET IDENTITY_INSERT`, `SET ANSI_NULLS`). The rest of
|
||||
* that family — `SAVE TRANSACTION`, the symmetric/master key statements,
|
||||
* `ADD SIGNATURE`, and `RAISERROR ... WITH LOG` — opens with a word that is also
|
||||
* an ordinary identifier, so it is screened as a two-token phrase in
|
||||
* {@link MSSQL_STATEMENT_PHRASES} instead.
|
||||
*
|
||||
* The text statements `UPDATETEXT`, `WRITETEXT`, and `READTEXT` are listed in
|
||||
* their own right rather than left to `update`: there is no word boundary after
|
||||
@@ -197,6 +314,20 @@ export async function executeQuery(
|
||||
* but it introduces a second statement in exactly the same semicolon-less way,
|
||||
* which is what this list exists to reject.
|
||||
*
|
||||
* `RENAME` is documented T-SQL DDL — it applies to Azure Synapse Analytics
|
||||
* dedicated SQL pools and Analytics Platform System, both of which speak TDS on
|
||||
* port 1433 and are reachable with exactly the connection fields this block
|
||||
* exposes. `SELECT 1 RENAME OBJECT dbo.Customer TO Customer1` is a valid
|
||||
* semicolon-less batch that changes schema through an operation advertised as
|
||||
* read-only, and `RENAME DATABASE` and `RENAME OBJECT … COLUMN … TO …` reach it
|
||||
* the same way.
|
||||
*
|
||||
* `RECEIVE` is the Service Broker read that *removes* the messages it returns,
|
||||
* so it is a write in everything but name. Its siblings — `END`/`MOVE`/`GET`
|
||||
* `CONVERSATION` and `SEND ON CONVERSATION` — open with words that are ordinary
|
||||
* identifiers (`END` closes every `CASE`), so they are screened as phrases in
|
||||
* {@link MSSQL_STATEMENT_PHRASES} instead.
|
||||
*
|
||||
* `FETCH` is deliberately **absent**: `OFFSET … FETCH NEXT` is the standard
|
||||
* T-SQL paging clause, so screening it would reject the ordinary paged SELECT
|
||||
* this operation exists to run. Word boundaries keep the additions off ordinary
|
||||
@@ -204,7 +335,50 @@ export async function executeQuery(
|
||||
* @see https://learn.microsoft.com/en-us/sql/t-sql/statements/statements
|
||||
*/
|
||||
const MSSQL_STATEMENT_KEYWORDS =
|
||||
/\b(?:insert|update|updatetext|writetext|readtext|delete|merge|drop|create|alter|truncate|disable|enable|set|begin|commit|rollback|grant|revoke|deny|exec|execute|backup|restore|shutdown|reconfigure|dbcc|kill|checkpoint|use|bulk|revert|setuser|openrowset|opendatasource|openquery|openxml|waitfor|into)\b/i
|
||||
/\b(?:insert|update|updatetext|writetext|readtext|delete|merge|drop|create|alter|truncate|rename|receive|disable|enable|set|begin|commit|rollback|grant|revoke|deny|exec|execute|backup|restore|shutdown|reconfigure|dbcc|kill|checkpoint|use|bulk|revert|setuser|openrowset|opendatasource|openquery|openxml|waitfor|into|deallocate)\b/i
|
||||
|
||||
/**
|
||||
* The remaining session, transaction, cursor, and key-management statements,
|
||||
* every one of which is a valid semicolon-less second statement the single-word
|
||||
* list above cannot carry.
|
||||
*
|
||||
* Each is matched as a **two-token** phrase rather than a bare word, because the
|
||||
* leading words are ordinary identifiers: `open` and `close` are columns in any
|
||||
* price table, `save` and `add` are common verbs, and `END` closes every `CASE`.
|
||||
* Screening those bare would reject the plain SELECTs this operation exists to
|
||||
* run. `DEALLOCATE` is the one exception and lives in the word list above — it
|
||||
* has no ordinary-identifier reading.
|
||||
*
|
||||
* Most of these write neither table data nor schema, which is why they were
|
||||
* missed; they are screened because the file's stated rule is that a second
|
||||
* statement is rejected structurally, not by what it happens to do.
|
||||
* `RAISERROR ... WITH LOG` writes to the error log and the Windows application
|
||||
* log, so it is not inert. The Service Broker conversation statements are not
|
||||
* inert either: `END CONVERSATION ... WITH CLEANUP` drops every message in a
|
||||
* conversation, `MOVE CONVERSATION` reassigns it, and `SEND ON CONVERSATION`
|
||||
* enqueues a message — and the handles they need are enumerable through this
|
||||
* same path, because the catalog screen applies only to WHERE clauses.
|
||||
* @see https://learn.microsoft.com/en-us/sql/t-sql/statements/end-conversation-transact-sql
|
||||
* @see https://learn.microsoft.com/en-us/sql/t-sql/statements/statements
|
||||
*/
|
||||
const MSSQL_STATEMENT_PHRASES: readonly RegExp[] = [
|
||||
/\bsave\s+tran(?:saction)?\b/i,
|
||||
/\bopen\s+(?:symmetric|master)\s+key\b/i,
|
||||
/\bclose\s+(?:all\s+symmetric\s+keys|master\s+key|symmetric\s+key)\b/i,
|
||||
/\badd\s+signature\b/i,
|
||||
/\braiserror[\s\S]*?\bwith\s+log\b/i,
|
||||
/\b(?:end|move|get)\s+conversation\b/i,
|
||||
/\bsend\s+on\s+conversation\b/i,
|
||||
]
|
||||
|
||||
/** Matches the first screened statement phrase, or `null`. */
|
||||
function matchStatementPhrase(masked: string): string | null {
|
||||
for (const pattern of MSSQL_STATEMENT_PHRASES) {
|
||||
const match = pattern.exec(masked)
|
||||
if (match) return match[0]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Extended, OLE-automation, and system stored procedures, called with or without `EXEC`. */
|
||||
const MSSQL_PROCEDURE_PATTERN = /\b(?:xp_|sp_)\w+/i
|
||||
@@ -355,11 +529,14 @@ export function validateReadOnlyQuery(query: string): { isValid: boolean; error?
|
||||
}
|
||||
}
|
||||
|
||||
const disallowed = MSSQL_STATEMENT_KEYWORDS.exec(masked) ?? MSSQL_PROCEDURE_PATTERN.exec(masked)
|
||||
const disallowed =
|
||||
MSSQL_STATEMENT_KEYWORDS.exec(masked)?.[0] ??
|
||||
MSSQL_PROCEDURE_PATTERN.exec(masked)?.[0] ??
|
||||
matchStatementPhrase(masked)
|
||||
if (disallowed) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `The Query operation cannot run ${disallowed[0].toUpperCase()}. Use the Execute Raw SQL operation for statements that modify data, schema, or server state.`,
|
||||
error: `The Query operation cannot run ${disallowed.toUpperCase()}. Use the Execute Raw SQL operation for statements that modify data, schema, or server state.`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,6 +696,7 @@ function validateWhereClause(where: string): void {
|
||||
const masked = maskSqlStringLiterals(where)
|
||||
if (
|
||||
MSSQL_STATEMENT_KEYWORDS.test(masked) ||
|
||||
matchStatementPhrase(masked) !== null ||
|
||||
MSSQL_PROCEDURE_PATTERN.test(masked) ||
|
||||
MSSQL_WHERE_SELECT.test(masked) ||
|
||||
MSSQL_WHERE_CONSTANT_TAUTOLOGY.some((pattern) => pattern.test(masked)) ||
|
||||
@@ -592,6 +770,7 @@ interface TableRow {
|
||||
}
|
||||
|
||||
interface ColumnRow {
|
||||
TABLE_NAME: string
|
||||
COLUMN_NAME: string
|
||||
DATA_TYPE: string
|
||||
IS_NULLABLE: string
|
||||
@@ -599,10 +778,12 @@ interface ColumnRow {
|
||||
}
|
||||
|
||||
interface KeyColumnRow {
|
||||
TABLE_NAME: string
|
||||
COLUMN_NAME: string
|
||||
}
|
||||
|
||||
interface ForeignKeyRow {
|
||||
TABLE_NAME: string
|
||||
COLUMN_NAME: string
|
||||
REFERENCED_TABLE_SCHEMA: string
|
||||
REFERENCED_TABLE_NAME: string
|
||||
@@ -610,6 +791,7 @@ interface ForeignKeyRow {
|
||||
}
|
||||
|
||||
interface IndexRow {
|
||||
TABLE_NAME: string
|
||||
INDEX_NAME: string
|
||||
COLUMN_NAME: string
|
||||
IS_UNIQUE: boolean | number
|
||||
@@ -654,75 +836,142 @@ export async function executeIntrospect(
|
||||
ORDER BY TABLE_NAME`
|
||||
)
|
||||
|
||||
const tableRows = tablesResult.recordset as TableRow[]
|
||||
if (tableRows.length === 0) return { tables: [], schemas }
|
||||
|
||||
/**
|
||||
* The column, primary key, foreign key, and index reads below are filtered by
|
||||
* schema and grouped in memory, rather than run once per table. Per-table they
|
||||
* were four round trips each — a 500-table schema meant ~2,000 sequential
|
||||
* queries, every one under its own connection timeout.
|
||||
*/
|
||||
const columnsResult = await pool
|
||||
.request()
|
||||
.input('schema', schemaName)
|
||||
.query<ColumnRow>(
|
||||
`SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @schema
|
||||
ORDER BY TABLE_NAME, ORDINAL_POSITION`
|
||||
)
|
||||
|
||||
const pkResult = await pool
|
||||
.request()
|
||||
.input('schema', schemaName)
|
||||
.query<KeyColumnRow>(
|
||||
`SELECT tc.TABLE_NAME, kcu.COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
|
||||
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
|
||||
AND tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
|
||||
WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
|
||||
AND tc.TABLE_SCHEMA = @schema
|
||||
ORDER BY tc.TABLE_NAME, kcu.ORDINAL_POSITION`
|
||||
)
|
||||
|
||||
const fkResult = await pool
|
||||
.request()
|
||||
.input('schema', schemaName)
|
||||
.query<ForeignKeyRow>(
|
||||
/**
|
||||
* Resolved through the catalog views rather than
|
||||
* `INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS`, which reaches the
|
||||
* referenced side by joining `TABLE_CONSTRAINTS` — a view that returns
|
||||
* "one row for each table constraint" and so has no row at all when a
|
||||
* foreign key references a unique *index*, silently dropping the key.
|
||||
* The catalog views resolve the referenced table and column by ID.
|
||||
* @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-foreign-key-columns-transact-sql
|
||||
* @see https://learn.microsoft.com/en-us/sql/relational-databases/system-information-schema-views/table-constraints-transact-sql
|
||||
*/
|
||||
`SELECT
|
||||
pt.name AS TABLE_NAME,
|
||||
pc.name AS COLUMN_NAME,
|
||||
rs.name AS REFERENCED_TABLE_SCHEMA,
|
||||
rt.name AS REFERENCED_TABLE_NAME,
|
||||
rc.name AS REFERENCED_COLUMN_NAME
|
||||
FROM sys.foreign_keys fk
|
||||
JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
|
||||
JOIN sys.tables pt ON pt.object_id = fk.parent_object_id
|
||||
JOIN sys.schemas ps ON ps.schema_id = pt.schema_id
|
||||
JOIN sys.columns pc
|
||||
ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id
|
||||
JOIN sys.tables rt ON rt.object_id = fkc.referenced_object_id
|
||||
JOIN sys.schemas rs ON rs.schema_id = rt.schema_id
|
||||
JOIN sys.columns rc
|
||||
ON rc.object_id = fkc.referenced_object_id AND rc.column_id = fkc.referenced_column_id
|
||||
WHERE ps.name = @schema
|
||||
ORDER BY pt.name, fk.name, fkc.constraint_column_id`
|
||||
)
|
||||
|
||||
const indexResult = await pool
|
||||
.request()
|
||||
.input('schema', schemaName)
|
||||
.query<IndexRow>(
|
||||
/**
|
||||
* `key_ordinal > 0` restricts the result to key columns: it is the
|
||||
* "ordinal (1-based) within set of key-columns", and `0` marks INCLUDEd
|
||||
* non-key columns, partitioning columns, **and every column of an XML,
|
||||
* spatial, columnstore, or JSON index**. The partitioning columns are
|
||||
* why `is_included_column` alone is not enough — those report `0` for it
|
||||
* too. The index families are the cost of the filter: they contribute no
|
||||
* key column, so they are absent from the result rather than listed with
|
||||
* an empty column set. Rowstore keys, which is what a query planner
|
||||
* reader is after, are reported in full.
|
||||
*
|
||||
* `is_hypothetical = 0` drops the statistics-only indexes the Database
|
||||
* Engine Tuning Advisor leaves behind ("can't be used directly as a data
|
||||
* access path"), and `is_disabled = 0` drops indexes that exist but are
|
||||
* not maintained. Reporting either as a live index misleads.
|
||||
*
|
||||
* `is_primary_key = 0` keeps the primary key out, since `primaryKey`
|
||||
* carries it already. A UNIQUE *constraint* is deliberately left in: it
|
||||
* is a unique index and nothing else in the result reports it.
|
||||
* @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-index-columns-transact-sql
|
||||
* @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-indexes-transact-sql
|
||||
*/
|
||||
`SELECT t.name AS TABLE_NAME, i.name AS INDEX_NAME, c.name AS COLUMN_NAME,
|
||||
i.is_unique AS IS_UNIQUE
|
||||
FROM sys.indexes i
|
||||
JOIN sys.index_columns ic
|
||||
ON i.object_id = ic.object_id AND i.index_id = ic.index_id
|
||||
JOIN sys.columns c
|
||||
ON ic.object_id = c.object_id AND ic.column_id = c.column_id
|
||||
JOIN sys.tables t ON i.object_id = t.object_id
|
||||
JOIN sys.schemas s ON t.schema_id = s.schema_id
|
||||
WHERE s.name = @schema
|
||||
AND i.is_primary_key = 0
|
||||
AND i.is_hypothetical = 0
|
||||
AND i.is_disabled = 0
|
||||
AND i.name IS NOT NULL
|
||||
AND ic.key_ordinal > 0
|
||||
ORDER BY t.name, i.name, ic.key_ordinal`
|
||||
)
|
||||
|
||||
/** Groups rows by their `TABLE_NAME`, preserving each group's server order. */
|
||||
function groupByTable<TRow extends { TABLE_NAME: string }>(rows: TRow[]): Map<string, TRow[]> {
|
||||
const grouped = new Map<string, TRow[]>()
|
||||
for (const row of rows) {
|
||||
const existing = grouped.get(row.TABLE_NAME)
|
||||
if (existing) existing.push(row)
|
||||
else grouped.set(row.TABLE_NAME, [row])
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
const columnsByTable = groupByTable(columnsResult.recordset as ColumnRow[])
|
||||
const pkByTable = groupByTable(pkResult.recordset as KeyColumnRow[])
|
||||
const fkByTable = groupByTable(fkResult.recordset as ForeignKeyRow[])
|
||||
const indexRowsByTable = groupByTable(indexResult.recordset as IndexRow[])
|
||||
|
||||
const tables: MSSQLIntrospectionResult['tables'] = []
|
||||
|
||||
for (const tableRow of tablesResult.recordset as TableRow[]) {
|
||||
for (const tableRow of tableRows) {
|
||||
const tableName = tableRow.TABLE_NAME
|
||||
const tableSchema = tableRow.TABLE_SCHEMA
|
||||
|
||||
const columnsResult = await pool
|
||||
.request()
|
||||
.input('schema', tableSchema)
|
||||
.input('table', tableName)
|
||||
.query<ColumnRow>(
|
||||
`SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table
|
||||
ORDER BY ORDINAL_POSITION`
|
||||
)
|
||||
const primaryKeyColumns = (pkByTable.get(tableName) ?? []).map((row) => row.COLUMN_NAME)
|
||||
|
||||
const pkResult = await pool
|
||||
.request()
|
||||
.input('schema', tableSchema)
|
||||
.input('table', tableName)
|
||||
.query<KeyColumnRow>(
|
||||
`SELECT kcu.COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
|
||||
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
|
||||
AND tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
|
||||
WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
|
||||
AND tc.TABLE_SCHEMA = @schema
|
||||
AND tc.TABLE_NAME = @table
|
||||
ORDER BY kcu.ORDINAL_POSITION`
|
||||
)
|
||||
const primaryKeyColumns = pkResult.recordset.map((row: KeyColumnRow) => row.COLUMN_NAME)
|
||||
|
||||
const fkResult = await pool
|
||||
.request()
|
||||
.input('schema', tableSchema)
|
||||
.input('table', tableName)
|
||||
.query<ForeignKeyRow>(
|
||||
/**
|
||||
* Resolved through the catalog views rather than
|
||||
* `INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS`, which reaches the
|
||||
* referenced side by joining `TABLE_CONSTRAINTS` — a view that returns
|
||||
* "one row for each table constraint" and so has no row at all when a
|
||||
* foreign key references a unique *index*, silently dropping the key.
|
||||
* The catalog views resolve the referenced table and column by ID.
|
||||
* @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-foreign-key-columns-transact-sql
|
||||
* @see https://learn.microsoft.com/en-us/sql/relational-databases/system-information-schema-views/table-constraints-transact-sql
|
||||
*/
|
||||
`SELECT
|
||||
pc.name AS COLUMN_NAME,
|
||||
rs.name AS REFERENCED_TABLE_SCHEMA,
|
||||
rt.name AS REFERENCED_TABLE_NAME,
|
||||
rc.name AS REFERENCED_COLUMN_NAME
|
||||
FROM sys.foreign_keys fk
|
||||
JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
|
||||
JOIN sys.tables pt ON pt.object_id = fk.parent_object_id
|
||||
JOIN sys.schemas ps ON ps.schema_id = pt.schema_id
|
||||
JOIN sys.columns pc
|
||||
ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id
|
||||
JOIN sys.tables rt ON rt.object_id = fkc.referenced_object_id
|
||||
JOIN sys.schemas rs ON rs.schema_id = rt.schema_id
|
||||
JOIN sys.columns rc
|
||||
ON rc.object_id = fkc.referenced_object_id AND rc.column_id = fkc.referenced_column_id
|
||||
WHERE ps.name = @schema AND pt.name = @table
|
||||
ORDER BY fk.name, fkc.constraint_column_id`
|
||||
)
|
||||
|
||||
const foreignKeys = fkResult.recordset.map((row: ForeignKeyRow) => ({
|
||||
const foreignKeys = (fkByTable.get(tableName) ?? []).map((row) => ({
|
||||
column: row.COLUMN_NAME,
|
||||
referencesSchema: row.REFERENCED_TABLE_SCHEMA,
|
||||
referencesTable: row.REFERENCED_TABLE_NAME,
|
||||
@@ -734,53 +983,8 @@ export async function executeIntrospect(
|
||||
if (!fkByColumn.has(fk.column)) fkByColumn.set(fk.column, fk)
|
||||
}
|
||||
|
||||
const indexResult = await pool
|
||||
.request()
|
||||
.input('schema', tableSchema)
|
||||
.input('table', tableName)
|
||||
.query<IndexRow>(
|
||||
/**
|
||||
* `key_ordinal > 0` restricts the result to key columns: it is the
|
||||
* "ordinal (1-based) within set of key-columns", and `0` marks INCLUDEd
|
||||
* non-key columns, partitioning columns, **and every column of an XML,
|
||||
* spatial, columnstore, or JSON index**. The partitioning columns are
|
||||
* why `is_included_column` alone is not enough — those report `0` for it
|
||||
* too. The index families are the cost of the filter: they contribute no
|
||||
* key column, so they are absent from the result rather than listed with
|
||||
* an empty column set. Rowstore keys, which is what a query planner
|
||||
* reader is after, are reported in full.
|
||||
*
|
||||
* `is_hypothetical = 0` drops the statistics-only indexes the Database
|
||||
* Engine Tuning Advisor leaves behind ("can't be used directly as a data
|
||||
* access path"), and `is_disabled = 0` drops indexes that exist but are
|
||||
* not maintained. Reporting either as a live index misleads.
|
||||
*
|
||||
* `is_primary_key = 0` keeps the primary key out, since `primaryKey`
|
||||
* carries it already. A UNIQUE *constraint* is deliberately left in: it
|
||||
* is a unique index and nothing else in the result reports it.
|
||||
* @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-index-columns-transact-sql
|
||||
* @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-indexes-transact-sql
|
||||
*/
|
||||
`SELECT i.name AS INDEX_NAME, c.name AS COLUMN_NAME, i.is_unique AS IS_UNIQUE
|
||||
FROM sys.indexes i
|
||||
JOIN sys.index_columns ic
|
||||
ON i.object_id = ic.object_id AND i.index_id = ic.index_id
|
||||
JOIN sys.columns c
|
||||
ON ic.object_id = c.object_id AND ic.column_id = c.column_id
|
||||
JOIN sys.tables t ON i.object_id = t.object_id
|
||||
JOIN sys.schemas s ON t.schema_id = s.schema_id
|
||||
WHERE s.name = @schema
|
||||
AND t.name = @table
|
||||
AND i.is_primary_key = 0
|
||||
AND i.is_hypothetical = 0
|
||||
AND i.is_disabled = 0
|
||||
AND i.name IS NOT NULL
|
||||
AND ic.key_ordinal > 0
|
||||
ORDER BY i.name, ic.key_ordinal`
|
||||
)
|
||||
|
||||
const indexMap = new Map<string, { name: string; columns: string[]; unique: boolean }>()
|
||||
for (const row of indexResult.recordset as IndexRow[]) {
|
||||
for (const row of indexRowsByTable.get(tableName) ?? []) {
|
||||
const indexName = row.INDEX_NAME
|
||||
if (!indexMap.has(indexName)) {
|
||||
indexMap.set(indexName, { name: indexName, columns: [], unique: Boolean(row.IS_UNIQUE) })
|
||||
@@ -791,7 +995,7 @@ export async function executeIntrospect(
|
||||
|
||||
const primaryKeySet = new Set(primaryKeyColumns)
|
||||
|
||||
const columns = columnsResult.recordset.map((col: ColumnRow) => {
|
||||
const columns = (columnsByTable.get(tableName) ?? []).map((col) => {
|
||||
const columnName = col.COLUMN_NAME
|
||||
const fk = fkByColumn.get(columnName)
|
||||
|
||||
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
/** jsdom ships no ResizeObserver; the editor observes its container to size the gutter. */
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver
|
||||
|
||||
const { SECRET, searchTargetRef } = vi.hoisted(() => ({
|
||||
SECRET: 'SIM-TEST-CREDENTIAL-MARKER\nfixture-body-abc123\nend-of-fixture',
|
||||
searchTargetRef: { current: null as Record<string, unknown> | null },
|
||||
}))
|
||||
|
||||
vi.mock('@sim/emcn', () => ({
|
||||
CODE_LINE_HEIGHT_PX: 21,
|
||||
Code: {
|
||||
Container: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Gutter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Content: ({
|
||||
children,
|
||||
editorRef,
|
||||
}: {
|
||||
children: ReactNode
|
||||
editorRef?: React.RefObject<HTMLDivElement | null>
|
||||
}) => <div ref={editorRef}>{children}</div>,
|
||||
Placeholder: ({ children, show }: { children: ReactNode; show: boolean }) =>
|
||||
show ? <div>{children}</div> : null,
|
||||
},
|
||||
calculateGutterWidth: () => 24,
|
||||
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
|
||||
Duplicate: () => null,
|
||||
getCodeEditorProps: () => ({}),
|
||||
highlight: (code: string) => code,
|
||||
languages: { javascript: {}, python: {}, bash: {} },
|
||||
}))
|
||||
|
||||
vi.mock('@sim/emcn/icons', () => ({
|
||||
Check: () => null,
|
||||
Wand: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('react-simple-code-editor', () => ({
|
||||
default: ({
|
||||
value,
|
||||
highlight,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: {
|
||||
value: string
|
||||
highlight: (code: string) => string
|
||||
onFocus: () => void
|
||||
onBlur: () => void
|
||||
}) => (
|
||||
<>
|
||||
<textarea
|
||||
data-testid='code-textarea'
|
||||
value={value}
|
||||
readOnly
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<pre data-testid='code-highlight' dangerouslySetInnerHTML={{ __html: highlight(value) }} />
|
||||
</>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children }: { children?: ReactNode }) => <button type='button'>{children}</button>,
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ workspaceId: 'workspace-1' }),
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown',
|
||||
() => ({
|
||||
EnvVarDropdown: () => null,
|
||||
checkEnvVarTrigger: () => ({ show: false, searchTerm: '' }),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown',
|
||||
() => ({
|
||||
TagDropdown: () => null,
|
||||
checkTagTrigger: () => ({ show: false }),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value',
|
||||
() => ({
|
||||
useSubBlockValue: (_blockId: string, subBlockId: string) => [
|
||||
subBlockId === 'privateKey' ? SECRET : undefined,
|
||||
() => {},
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider',
|
||||
() => ({ useActiveSearchTarget: () => searchTargetRef.current })
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar',
|
||||
() => ({
|
||||
WandPromptBar: () => null,
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes',
|
||||
() => ({ useAccessibleReferencePrefixes: () => undefined })
|
||||
)
|
||||
|
||||
vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand', () => ({
|
||||
useWand: () => ({
|
||||
isLoading: false,
|
||||
isStreaming: false,
|
||||
isPromptVisible: false,
|
||||
promptInputValue: '',
|
||||
generateStream: () => {},
|
||||
cancelGeneration: () => {},
|
||||
showPromptInline: () => {},
|
||||
hidePromptInline: () => {},
|
||||
updatePromptValue: () => {},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/kb/use-tag-selection', () => ({ useTagSelection: () => () => {} }))
|
||||
|
||||
vi.mock('@/hooks/use-available-env-vars', () => ({
|
||||
useAvailableEnvVarKeys: () => [],
|
||||
createShouldHighlightEnvVar: () => () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-code-undo-redo', () => ({
|
||||
useCodeUndoRedo: () => ({
|
||||
recordChange: () => {},
|
||||
recordReplace: () => {},
|
||||
flushPending: () => {},
|
||||
startSession: () => {},
|
||||
undo: () => {},
|
||||
redo: () => {},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflows/workflow/store', () => ({
|
||||
useWorkflowStore: (selector: (state: { blocks: Record<string, unknown> }) => unknown) =>
|
||||
selector({ blocks: {} }),
|
||||
}))
|
||||
|
||||
import { Code } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
searchTargetRef.current = null
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function mount(password: boolean) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<Code
|
||||
blockId='block-1'
|
||||
subBlockId='privateKey'
|
||||
password={password}
|
||||
wandConfig={{ enabled: false, prompt: '' }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const highlighted = () => container.querySelector('[data-testid="code-highlight"]')?.innerHTML ?? ''
|
||||
|
||||
/** A live workflow-search hit on the base64 body of the secret. */
|
||||
const SECRET_MATCH = 'fixture-body-abc123'
|
||||
const SECRET_MATCH_START = SECRET.indexOf(SECRET_MATCH)
|
||||
const SECRET_SEARCH_TARGET = {
|
||||
subBlockId: 'privateKey',
|
||||
targetKind: 'subblock',
|
||||
valuePath: [],
|
||||
query: SECRET_MATCH,
|
||||
rawValue: SECRET_MATCH,
|
||||
range: { start: SECRET_MATCH_START, end: SECRET_MATCH_START + SECRET_MATCH.length },
|
||||
}
|
||||
|
||||
describe('Code password masking', () => {
|
||||
it('conceals the editor contents while unfocused', () => {
|
||||
mount(true)
|
||||
|
||||
expect(highlighted()).not.toContain('SIM-TEST-CREDENTIAL-MARKER')
|
||||
expect(highlighted()).not.toContain('b3BlbnNzaC1rZXktdjE')
|
||||
expect(highlighted()).toContain('•')
|
||||
})
|
||||
|
||||
it('reveals the contents once the editor takes focus and re-masks on blur', () => {
|
||||
mount(true)
|
||||
|
||||
const textarea = container.querySelector('[data-testid="code-textarea"]') as HTMLTextAreaElement
|
||||
act(() => {
|
||||
textarea.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
|
||||
})
|
||||
expect(highlighted()).toContain('SIM-TEST-CREDENTIAL-MARKER')
|
||||
|
||||
act(() => {
|
||||
textarea.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
|
||||
})
|
||||
expect(highlighted()).not.toContain('SIM-TEST-CREDENTIAL-MARKER')
|
||||
})
|
||||
|
||||
it('leaves a non-password code field in plaintext', () => {
|
||||
mount(false)
|
||||
|
||||
expect(highlighted()).toContain('SIM-TEST-CREDENTIAL-MARKER')
|
||||
expect(highlighted()).not.toContain('•')
|
||||
})
|
||||
|
||||
it('stays concealed while workflow search targets a match inside the secret', () => {
|
||||
searchTargetRef.current = SECRET_SEARCH_TARGET
|
||||
|
||||
mount(true)
|
||||
|
||||
expect(highlighted()).not.toContain('b3BlbnNzaC1rZXktdjE')
|
||||
expect(highlighted()).not.toContain('SIM-TEST-CREDENTIAL-MARKER')
|
||||
expect(highlighted()).toContain('•')
|
||||
})
|
||||
|
||||
it('highlights a targeted match when the field holds no secret', () => {
|
||||
searchTargetRef.current = SECRET_SEARCH_TARGET
|
||||
|
||||
mount(false)
|
||||
|
||||
expect(highlighted()).toContain('<mark')
|
||||
expect(highlighted()).toContain(SECRET_MATCH)
|
||||
})
|
||||
})
|
||||
+28
-1
@@ -30,6 +30,10 @@ import {
|
||||
getValidWorkflowSearchRange,
|
||||
type WorkflowSearchTextHighlight,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
import {
|
||||
maskSecretText,
|
||||
shouldMaskSecretValue,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/password-mask'
|
||||
import {
|
||||
checkTagTrigger,
|
||||
TagDropdown,
|
||||
@@ -137,6 +141,21 @@ const escapeHtml = (value: string): string =>
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
|
||||
/**
|
||||
* Highlighter that conceals the editor's contents.
|
||||
*
|
||||
* @remarks
|
||||
* `react-simple-code-editor` paints its textarea with a transparent text fill
|
||||
* and shows the markup returned by its `highlight` prop instead, so swapping the
|
||||
* highlighter is what actually hides a secret — the textarea's own value is
|
||||
* never visible.
|
||||
*
|
||||
* @param codeToHighlight - The plaintext editor contents
|
||||
* @returns Escaped markup with every character replaced by a mask glyph
|
||||
*/
|
||||
export const highlightMaskedCode = (codeToHighlight: string): string =>
|
||||
escapeHtml(maskSecretText(codeToHighlight))
|
||||
|
||||
/**
|
||||
* Type definition for code placeholders during syntax highlighting.
|
||||
*/
|
||||
@@ -234,6 +253,8 @@ interface CodeProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
placeholder?: string
|
||||
/** Whether to conceal the value except while the editor is focused */
|
||||
password?: boolean
|
||||
language?: 'javascript' | 'json' | 'python' | 'shell'
|
||||
generationType?: GenerationType
|
||||
value?: string
|
||||
@@ -263,6 +284,7 @@ export const Code = memo(function Code({
|
||||
blockId,
|
||||
subBlockId,
|
||||
placeholder = 'Write JavaScript...',
|
||||
password = false,
|
||||
language = 'javascript',
|
||||
generationType = 'javascript-function-body',
|
||||
value: propValue,
|
||||
@@ -290,6 +312,7 @@ export const Code = memo(function Code({
|
||||
const [visualLineHeights, setVisualLineHeights] = useState<number[]>([])
|
||||
const [activeLineNumber, setActiveLineNumber] = useState(1)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const handleStreamStartRef = useRef<() => void>(() => {})
|
||||
@@ -743,6 +766,8 @@ export const Code = memo(function Code({
|
||||
valuePath: [],
|
||||
})
|
||||
|
||||
const shouldMask = shouldMaskSecretValue({ password, isFocused })
|
||||
|
||||
const highlightCode = useMemo(
|
||||
() =>
|
||||
createHighlightFunction(
|
||||
@@ -812,6 +837,7 @@ export const Code = memo(function Code({
|
||||
)
|
||||
|
||||
const handleEditorFocus = useCallback(() => {
|
||||
setIsFocused(true)
|
||||
startSession(codeRef.current)
|
||||
if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') {
|
||||
setShowTags(true)
|
||||
@@ -820,6 +846,7 @@ export const Code = memo(function Code({
|
||||
}, [disabled, isPreview, readOnly, startSession])
|
||||
|
||||
const handleEditorBlur = useCallback(() => {
|
||||
setIsFocused(false)
|
||||
flushPending()
|
||||
}, [flushPending])
|
||||
|
||||
@@ -942,7 +969,7 @@ export const Code = memo(function Code({
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleEditorFocus}
|
||||
onBlur={handleEditorBlur}
|
||||
highlight={highlightCode}
|
||||
highlight={shouldMask ? highlightMaskedCode : highlightCode}
|
||||
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
|
||||
/>
|
||||
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ export { LongInput } from './long-input'
|
||||
export { McpDynamicArgs } from './mcp-dynamic-args'
|
||||
export { McpServerSelector, McpToolSelector } from './mcp-server-modal'
|
||||
export { MessagesInput } from './messages-input'
|
||||
export { maskSecretText, PASSWORD_MASKED_SUBBLOCK_TYPES } from './password-mask'
|
||||
export { ResponseFormat } from './response'
|
||||
export { ScheduleInfo } from './schedule-info'
|
||||
export { SelectorInput, type SelectorOverrides } from './selector-input'
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { SECRET, searchTargetRef } = vi.hoisted(() => ({
|
||||
SECRET: 'SIM-TEST-CREDENTIAL-MARKER\nfixture-body-abc\nend-of-fixture',
|
||||
searchTargetRef: { current: null as Record<string, unknown> | null },
|
||||
}))
|
||||
|
||||
vi.mock('@sim/emcn', () => ({
|
||||
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
|
||||
Textarea: (props: Record<string, unknown>) => <textarea {...props} />,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/emcn/icons', () => ({
|
||||
ChevronsUpDown: () => null,
|
||||
Wand: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children }: { children?: React.ReactNode }) => (
|
||||
<button type='button'>{children}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller',
|
||||
() => ({
|
||||
SubBlockInputController: ({
|
||||
children,
|
||||
}: {
|
||||
children: (props: Record<string, unknown>) => React.ReactNode
|
||||
}) =>
|
||||
children({
|
||||
ref: { current: null },
|
||||
onChange: () => {},
|
||||
onKeyDown: () => {},
|
||||
onDrop: () => {},
|
||||
onDragOver: () => {},
|
||||
onFocus: () => {},
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input',
|
||||
() => ({
|
||||
useSubBlockInput: () => ({ valueString: SECRET }),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value',
|
||||
() => ({
|
||||
useSubBlockValue: () => [SECRET, () => {}],
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider',
|
||||
() => ({
|
||||
useActiveSearchTarget: () => searchTargetRef.current,
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar',
|
||||
() => ({
|
||||
WandPromptBar: () => null,
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes',
|
||||
() => ({
|
||||
useAccessibleReferencePrefixes: () => undefined,
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand', () => ({
|
||||
useWand: () => ({
|
||||
isStreaming: false,
|
||||
isLoading: false,
|
||||
isPromptVisible: false,
|
||||
promptInputValue: '',
|
||||
generateStream: () => {},
|
||||
cancelGeneration: () => {},
|
||||
hidePromptInline: () => {},
|
||||
showPromptInline: () => {},
|
||||
updatePromptValue: () => {},
|
||||
}),
|
||||
}))
|
||||
|
||||
import { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
const config: SubBlockConfig = { id: 'privateKey', type: 'long-input', password: true }
|
||||
|
||||
/** A live workflow-search hit on the base64 body of the secret. */
|
||||
const SECRET_MATCH = 'fixture-body-abc'
|
||||
const MATCH_START = SECRET.indexOf(SECRET_MATCH)
|
||||
|
||||
function render(password: boolean) {
|
||||
return renderToStaticMarkup(
|
||||
<LongInput blockId='block-1' subBlockId='privateKey' config={config} password={password} />
|
||||
)
|
||||
}
|
||||
|
||||
describe('LongInput password masking', () => {
|
||||
beforeEach(() => {
|
||||
searchTargetRef.current = null
|
||||
})
|
||||
|
||||
it('conceals the value in both the textarea and the overlay when unfocused', () => {
|
||||
const html = render(true)
|
||||
|
||||
expect(html).not.toContain('SIM-TEST-CREDENTIAL-MARKER')
|
||||
expect(html).not.toContain('b3BlbnNzaC1rZXk')
|
||||
expect(html).toContain('•')
|
||||
})
|
||||
|
||||
it('renders the plaintext value when the field is not a password field', () => {
|
||||
const html = render(false)
|
||||
|
||||
expect(html).toContain('SIM-TEST-CREDENTIAL-MARKER')
|
||||
expect(html).not.toContain('•')
|
||||
})
|
||||
|
||||
it('stays concealed while workflow search targets a match inside the secret', () => {
|
||||
searchTargetRef.current = {
|
||||
blockId: 'block-1',
|
||||
subBlockId: 'privateKey',
|
||||
targetKind: 'subblock',
|
||||
valuePath: [],
|
||||
query: SECRET_MATCH,
|
||||
rawValue: SECRET_MATCH,
|
||||
range: { start: MATCH_START, end: MATCH_START + SECRET_MATCH.length },
|
||||
}
|
||||
|
||||
const html = render(true)
|
||||
|
||||
expect(html).not.toContain('b3BlbnNzaC1rZXk')
|
||||
expect(html).not.toContain('SIM-TEST-CREDENTIAL-MARKER')
|
||||
expect(html).toContain('•')
|
||||
})
|
||||
|
||||
it('highlights a workflow-search match when the field holds no secret', () => {
|
||||
searchTargetRef.current = {
|
||||
blockId: 'block-1',
|
||||
subBlockId: 'privateKey',
|
||||
targetKind: 'subblock',
|
||||
valuePath: [],
|
||||
query: SECRET_MATCH,
|
||||
rawValue: SECRET_MATCH,
|
||||
range: { start: MATCH_START, end: MATCH_START + SECRET_MATCH.length },
|
||||
}
|
||||
|
||||
const html = render(false)
|
||||
|
||||
expect(html).toContain('<mark')
|
||||
expect(html).toContain(SECRET_MATCH)
|
||||
})
|
||||
})
|
||||
+29
-7
@@ -12,6 +12,10 @@ import { ChevronsUpDown, Wand } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
import {
|
||||
maskSecretText,
|
||||
shouldMaskSecretValue,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/password-mask'
|
||||
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller'
|
||||
import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
|
||||
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
|
||||
@@ -46,6 +50,8 @@ const MIN_HEIGHT_PX = 80
|
||||
interface LongInputProps {
|
||||
/** Placeholder text to display when empty */
|
||||
placeholder?: string
|
||||
/** Whether to conceal the value except while the textarea is focused */
|
||||
password?: boolean
|
||||
/** Unique identifier for the block */
|
||||
blockId: string
|
||||
/** Unique identifier for the sub-block */
|
||||
@@ -79,10 +85,12 @@ interface LongInputProps {
|
||||
* - Handles drag-and-drop for connections and variable references
|
||||
* - Provides environment variable and tag autocomplete
|
||||
* - Resizable with custom drag handle
|
||||
* - Password masking, revealed only while focused
|
||||
* - Integrates with ReactFlow for zoom control
|
||||
*/
|
||||
export function LongInput({
|
||||
placeholder,
|
||||
password,
|
||||
blockId,
|
||||
subBlockId,
|
||||
config,
|
||||
@@ -99,6 +107,7 @@ export function LongInput({
|
||||
const activeSearchTarget = useActiveSearchTarget()
|
||||
// Local state for immediate UI updates during streaming
|
||||
const [localContent, setLocalContent] = useState<string>('')
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const persistSubBlockValueRef = useRef<(value: string) => void>(() => {})
|
||||
|
||||
// Wand functionality - always call the hook unconditionally
|
||||
@@ -191,6 +200,13 @@ export function LongInput({
|
||||
// During streaming, use local content; otherwise use the controller value
|
||||
const value = wandHook.isStreaming ? localContent : ctrl.valueString
|
||||
|
||||
const shouldMask = shouldMaskSecretValue({ password, isFocused })
|
||||
const displayValue = shouldMask ? maskSecretText(value) : value
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false)
|
||||
}, [])
|
||||
|
||||
// Base value for syncing (not including streaming)
|
||||
const baseValue = isPreview
|
||||
? previewValue
|
||||
@@ -338,13 +354,17 @@ export function LongInput({
|
||||
)}
|
||||
rows={rows ?? DEFAULT_ROWS}
|
||||
placeholder={placeholder ?? ''}
|
||||
value={value}
|
||||
value={displayValue}
|
||||
onChange={handleChange as (e: React.ChangeEvent<HTMLTextAreaElement>) => void}
|
||||
onDrop={onDrop as (e: React.DragEvent<HTMLTextAreaElement>) => void}
|
||||
onDragOver={onDragOver as (e: React.DragEvent<HTMLTextAreaElement>) => void}
|
||||
onScroll={handleScroll}
|
||||
onKeyDown={onKeyDown as (e: React.KeyboardEvent<HTMLTextAreaElement>) => void}
|
||||
onFocus={onFocus}
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true)
|
||||
onFocus(e)
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
disabled={isPreview || disabled}
|
||||
style={{
|
||||
fontFamily: 'inherit',
|
||||
@@ -368,11 +388,13 @@ export function LongInput({
|
||||
height: `${height}px`,
|
||||
}}
|
||||
>
|
||||
{formatDisplayText(value, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
workflowSearchHighlight,
|
||||
})}
|
||||
{shouldMask
|
||||
? displayValue
|
||||
: formatDisplayText(value, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
workflowSearchHighlight,
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Wand Button - only show if not hidden by parent */}
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
maskSecretText,
|
||||
PASSWORD_MASKED_SUBBLOCK_TYPES,
|
||||
shouldMaskSecretValue,
|
||||
} from './password-mask'
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
maskSecretText,
|
||||
PASSWORD_MASKED_SUBBLOCK_TYPES,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/password-mask'
|
||||
|
||||
describe('maskSecretText', () => {
|
||||
it('replaces every character of a single-line secret', () => {
|
||||
expect(maskSecretText('hunter2')).toBe('•••••••')
|
||||
})
|
||||
|
||||
it('leaves nothing of the plaintext behind', () => {
|
||||
const secret = 'SIM-TEST-CREDENTIAL-MARKER-value'
|
||||
const masked = maskSecretText(secret)
|
||||
expect(masked).not.toContain('SIM-TEST-CREDENTIAL-MARKER')
|
||||
expect(masked).not.toContain('KEY')
|
||||
expect(new Set(masked)).toEqual(new Set(['•']))
|
||||
})
|
||||
|
||||
it('preserves line breaks so a multi-line secret keeps its shape', () => {
|
||||
const value = 'marker-a\nabcd\nmarker-b'
|
||||
const masked = maskSecretText(value)
|
||||
expect(masked.split('\n')).toEqual(value.split('\n').map((line) => '•'.repeat(line.length)))
|
||||
})
|
||||
|
||||
it('returns an empty string for an empty value', () => {
|
||||
expect(maskSecretText('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PASSWORD_MASKED_SUBBLOCK_TYPES', () => {
|
||||
it('covers every renderer that reads the password flag', () => {
|
||||
expect([...PASSWORD_MASKED_SUBBLOCK_TYPES].sort()).toEqual([
|
||||
'code',
|
||||
'long-input',
|
||||
'short-input',
|
||||
'table',
|
||||
])
|
||||
})
|
||||
})
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Sub-block types whose editor honours `SubBlockConfig.password` by concealing
|
||||
* the stored value until the field is focused.
|
||||
*
|
||||
* @remarks
|
||||
* `password` lives on the shared `SubBlockConfig` type, so it is assignable to
|
||||
* every sub-block type but only has an effect where a renderer reads it. Setting
|
||||
* it on a type outside this list is a silent no-op that leaves a secret in
|
||||
* plaintext, so a new usage must teach that renderer to mask rather than rely on
|
||||
* the flag alone. `apps/sim/blocks/password-masking.test.ts` enforces this.
|
||||
*/
|
||||
export const PASSWORD_MASKED_SUBBLOCK_TYPES = [
|
||||
'short-input',
|
||||
'long-input',
|
||||
'code',
|
||||
'table',
|
||||
] as const
|
||||
|
||||
/** Glyph substituted for each concealed character. */
|
||||
const MASK_CHARACTER = '•'
|
||||
|
||||
/** Inputs to the single reveal policy shared by every masking renderer. */
|
||||
interface SecretMaskDecision {
|
||||
/** Whether `SubBlockConfig.password` is set on the field being rendered. */
|
||||
password?: boolean
|
||||
/** Whether the field — or, for a table, the individual cell — currently holds focus. */
|
||||
isFocused: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place that decides whether a `password`-flagged value is concealed.
|
||||
*
|
||||
* @remarks
|
||||
* Focus is the only reveal gesture. Putting the caret in the field is a
|
||||
* deliberate act by the person who owns the screen, and it is required anyway to
|
||||
* edit the value, so it costs nothing and reveals nothing the user did not ask
|
||||
* for.
|
||||
*
|
||||
* An active workflow-search match deliberately does **not** reveal. The search
|
||||
* index is built client-side from values the browser already holds, so
|
||||
* unmasking on a match leaks no privilege the caller lacks — but masking never
|
||||
* defended against the authenticated user. It defends against incidental
|
||||
* display: screenshares, recordings, and onlookers. Workflow search keeps focus
|
||||
* in its own input while merely scrolling the matched field into view, so a
|
||||
* search-driven reveal paints a private key somewhere the user is not even
|
||||
* looking, triggered by keystrokes typed into an unrelated box. That is exactly
|
||||
* the exposure the flag exists to prevent, and a guessable query makes it
|
||||
* repeatable. Navigation still works: the match scrolls into view masked, and
|
||||
* one click reveals it.
|
||||
*
|
||||
* Every renderer for a type in {@link PASSWORD_MASKED_SUBBLOCK_TYPES} must
|
||||
* derive its decision from this function rather than re-deriving a predicate, so
|
||||
* the four renderers cannot drift apart again.
|
||||
* `apps/sim/blocks/password-masking.test.ts` enforces that.
|
||||
*
|
||||
* @param decision - The field's password flag and current focus state
|
||||
* @returns Whether the value must be replaced with mask glyphs
|
||||
*/
|
||||
export function shouldMaskSecretValue({ password, isFocused }: SecretMaskDecision): boolean {
|
||||
return Boolean(password) && !isFocused
|
||||
}
|
||||
|
||||
/**
|
||||
* Conceals every character of a secret behind {@link MASK_CHARACTER}, leaving
|
||||
* line breaks intact so a masked multi-line value keeps the shape — and the
|
||||
* line count, gutter, and scroll extent — of the value it replaces.
|
||||
*
|
||||
* @param value - The plaintext value to conceal
|
||||
* @returns The value with every non-newline character replaced
|
||||
*/
|
||||
export function maskSecretText(value: string): string {
|
||||
return value.replace(/[^\n]/g, MASK_CHARACTER)
|
||||
}
|
||||
+8
-17
@@ -3,10 +3,11 @@ import { cn, Input } from '@sim/emcn'
|
||||
import { Wand } from '@sim/emcn/icons'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
import {
|
||||
formatDisplayText,
|
||||
getValidWorkflowSearchRange,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
maskSecretText,
|
||||
shouldMaskSecretValue,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/password-mask'
|
||||
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller'
|
||||
import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
@@ -50,8 +51,6 @@ interface ShortInputProps {
|
||||
wandControlRef?: React.MutableRefObject<WandControlHandlers | null>
|
||||
/** Whether to hide the internal wand button (controlled by parent) */
|
||||
hideInternalWand?: boolean
|
||||
/** Whether workflow search is actively highlighting this input */
|
||||
isSearchHighlighted?: boolean
|
||||
workflowSearchValuePath?: Array<string | number>
|
||||
}
|
||||
|
||||
@@ -63,7 +62,7 @@ interface ShortInputProps {
|
||||
* - Auto-detects API key fields and provides environment variable suggestions
|
||||
* - Handles drag-and-drop for connections and variable references
|
||||
* - Provides environment variable and tag autocomplete
|
||||
* - Password masking with reveal on focus
|
||||
* - Password masking, revealed only while focused
|
||||
* - Integrates with ReactFlow for zoom control
|
||||
*/
|
||||
export const ShortInput = memo(function ShortInput({
|
||||
@@ -81,7 +80,6 @@ export const ShortInput = memo(function ShortInput({
|
||||
useWebhookUrl = false,
|
||||
wandControlRef,
|
||||
hideInternalWand = false,
|
||||
isSearchHighlighted = false,
|
||||
workflowSearchValuePath = [],
|
||||
}: ShortInputProps) {
|
||||
const activeSearchTarget = useActiveSearchTarget()
|
||||
@@ -348,18 +346,11 @@ export const ShortInput = memo(function ShortInput({
|
||||
subBlockId,
|
||||
valuePath: workflowSearchValuePath,
|
||||
})
|
||||
const hasExactSearchHighlight = Boolean(
|
||||
getValidWorkflowSearchRange(actualValueString, workflowSearchHighlight)
|
||||
)
|
||||
|
||||
const shouldMask =
|
||||
password && !isFocused && !isSearchHighlighted && !hasExactSearchHighlight
|
||||
const displayValue = shouldMask
|
||||
? '•'.repeat(actualValueString.length)
|
||||
: actualValueString
|
||||
const shouldMask = shouldMaskSecretValue({ password, isFocused })
|
||||
const displayValue = shouldMask ? maskSecretText(actualValueString) : actualValueString
|
||||
|
||||
const formattedText = shouldMask
|
||||
? '•'.repeat(actualValueString.length)
|
||||
? maskSecretText(actualValueString)
|
||||
: formatDisplayText(actualValueString, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { KEY, SECRET, searchTargetRef } = vi.hoisted(() => ({
|
||||
KEY: 'BROWSER_LOGIN',
|
||||
SECRET: 'hunter2-plaintext',
|
||||
searchTargetRef: { current: null as Record<string, unknown> | null },
|
||||
}))
|
||||
|
||||
vi.mock('@sim/emcn', () => ({
|
||||
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
|
||||
Button: ({ children }: { children?: React.ReactNode }) => (
|
||||
<button type='button'>{children}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/emcn/icons', () => ({
|
||||
Trash: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ workspaceId: 'workspace-1' }),
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown',
|
||||
() => ({ EnvVarDropdown: () => null })
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown',
|
||||
() => ({ TagDropdown: () => null })
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input',
|
||||
() => ({
|
||||
useSubBlockInput: () => ({
|
||||
fieldHelpers: {
|
||||
getFieldState: () => ({
|
||||
showEnvVars: false,
|
||||
showTags: false,
|
||||
searchTerm: '',
|
||||
cursorPosition: 0,
|
||||
activeSourceBlockId: null,
|
||||
}),
|
||||
createFieldHandlers: () => ({
|
||||
onChange: () => {},
|
||||
onKeyDown: () => {},
|
||||
onDrop: () => {},
|
||||
onDragOver: () => {},
|
||||
onFocus: () => {},
|
||||
}),
|
||||
createTagSelectHandler: () => () => {},
|
||||
createEnvVarSelectHandler: () => () => {},
|
||||
hideFieldDropdowns: () => {},
|
||||
},
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value',
|
||||
() => ({
|
||||
useSubBlockValue: () => [[{ id: 'row-1', cells: { Key: KEY, Value: SECRET } }], () => {}],
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider',
|
||||
() => ({ useActiveSearchTarget: () => searchTargetRef.current })
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes',
|
||||
() => ({ useAccessibleReferencePrefixes: () => undefined })
|
||||
)
|
||||
|
||||
import { Table } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table'
|
||||
|
||||
function render(password: boolean) {
|
||||
return renderToStaticMarkup(
|
||||
<Table
|
||||
blockId='block-1'
|
||||
subBlockId='variables'
|
||||
columns={['Key', 'Value']}
|
||||
password={password}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** A live workflow-search hit on the value cell of the only row. */
|
||||
const VALUE_CELL_SEARCH_TARGET = {
|
||||
blockId: 'block-1',
|
||||
subBlockId: 'variables',
|
||||
targetKind: 'subblock',
|
||||
valuePath: [0, 'cells', 'Value'],
|
||||
query: SECRET,
|
||||
rawValue: SECRET,
|
||||
range: { start: 0, end: SECRET.length },
|
||||
}
|
||||
|
||||
describe('Table password masking', () => {
|
||||
beforeEach(() => {
|
||||
searchTargetRef.current = null
|
||||
})
|
||||
|
||||
it('conceals value cells while leaving the key column legible', () => {
|
||||
const html = render(true)
|
||||
|
||||
expect(html).not.toContain(SECRET)
|
||||
expect(html).toContain(KEY)
|
||||
expect(html).toContain('•')
|
||||
})
|
||||
|
||||
it('renders plaintext cells when the sub-block is not a password field', () => {
|
||||
const html = render(false)
|
||||
|
||||
expect(html).toContain(SECRET)
|
||||
expect(html).toContain(KEY)
|
||||
expect(html).not.toContain('•')
|
||||
})
|
||||
|
||||
it('keeps a value cell concealed while workflow search targets it', () => {
|
||||
searchTargetRef.current = VALUE_CELL_SEARCH_TARGET
|
||||
|
||||
const html = render(true)
|
||||
|
||||
expect(html).not.toContain(SECRET)
|
||||
expect(html).toContain('•')
|
||||
})
|
||||
|
||||
it('highlights a targeted value cell when the sub-block holds no secret', () => {
|
||||
searchTargetRef.current = VALUE_CELL_SEARCH_TARGET
|
||||
|
||||
const html = render(false)
|
||||
|
||||
expect(html).toContain('<mark')
|
||||
expect(html).toContain(SECRET)
|
||||
})
|
||||
})
|
||||
+33
-8
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, cn } from '@sim/emcn'
|
||||
import { Trash } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
@@ -6,6 +6,10 @@ import { generateId } from '@sim/utils/id'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { EnvVarDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
import {
|
||||
maskSecretText,
|
||||
shouldMaskSecretValue,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/password-mask'
|
||||
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
import {
|
||||
getActiveWorkflowSearchHighlight,
|
||||
@@ -22,6 +26,12 @@ interface TableProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
columns: string[]
|
||||
/**
|
||||
* Conceals the value columns except while a cell is focused. The first column is the
|
||||
* key half of a key/value secrets table and stays legible — masking it would
|
||||
* leave the user unable to tell the rows apart.
|
||||
*/
|
||||
password?: boolean
|
||||
isPreview?: boolean
|
||||
previewValue?: WorkflowTableRow[] | null
|
||||
disabled?: boolean
|
||||
@@ -38,6 +48,8 @@ interface TableCellProps {
|
||||
column: string
|
||||
cellIndex: number
|
||||
columnsCount: number
|
||||
/** Whether this cell holds a secret and must be concealed while unfocused */
|
||||
password: boolean
|
||||
isPreview: boolean
|
||||
disabled: boolean
|
||||
blockId: string
|
||||
@@ -56,6 +68,7 @@ function TableCell({
|
||||
column,
|
||||
cellIndex,
|
||||
columnsCount,
|
||||
password,
|
||||
isPreview,
|
||||
disabled,
|
||||
blockId,
|
||||
@@ -68,6 +81,7 @@ function TableCell({
|
||||
subBlockId,
|
||||
}: TableCellProps) {
|
||||
const activeSearchTarget = useActiveSearchTarget()
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
// Defensive programming: ensure row.cells exists and has the expected structure
|
||||
const hasValidCells = row.cells && typeof row.cells === 'object'
|
||||
if (!hasValidCells) logger.warn('Table row has malformed cells data:', row)
|
||||
@@ -83,6 +97,9 @@ function TableCell({
|
||||
valuePath: [rowIndex, 'cells', column],
|
||||
})
|
||||
|
||||
const shouldMask = shouldMaskSecretValue({ password, isFocused })
|
||||
const displayValue = shouldMask ? maskSecretText(cellValue) : cellValue
|
||||
|
||||
// Get field state and handlers for this cell
|
||||
const fieldState = inputController.fieldHelpers.getFieldState(cellKey)
|
||||
const handlers = inputController.fieldHelpers.createFieldHandlers(
|
||||
@@ -176,14 +193,18 @@ function TableCell({
|
||||
else inputRefs.current.delete(cellKey)
|
||||
}}
|
||||
type='text'
|
||||
value={cellValue}
|
||||
value={displayValue}
|
||||
placeholder={column}
|
||||
onChange={handlers.onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={handleScroll}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true)
|
||||
handlers.onFocus(e)
|
||||
}}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
disabled={isPreview || disabled}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
@@ -200,11 +221,13 @@ function TableCell({
|
||||
className='scrollbar-hide pointer-events-none absolute top-0 right-[10px] bottom-0 left-[10px] overflow-x-auto overflow-y-hidden bg-transparent'
|
||||
>
|
||||
<div className='whitespace-pre py-2 text-[var(--text-primary)] text-sm leading-[21px]'>
|
||||
{formatDisplayText(cellValue, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
workflowSearchHighlight,
|
||||
})}
|
||||
{shouldMask
|
||||
? displayValue
|
||||
: formatDisplayText(cellValue, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
workflowSearchHighlight,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{fieldState.showEnvVars && (
|
||||
@@ -248,6 +271,7 @@ export function Table({
|
||||
blockId,
|
||||
subBlockId,
|
||||
columns,
|
||||
password = false,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
disabled = false,
|
||||
@@ -424,6 +448,7 @@ export function Table({
|
||||
column={column}
|
||||
cellIndex={cellIndex}
|
||||
columnsCount={columns.length}
|
||||
password={password && cellIndex > 0}
|
||||
isPreview={isPreview}
|
||||
disabled={disabled}
|
||||
blockId={blockId}
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Every input the sub-block renderer can pick renders the same probe, so an
|
||||
* assertion can read back whether the renderer forwarded `config.password` for
|
||||
* a given sub-block type. A type that drops the flag renders `password="off"`.
|
||||
*/
|
||||
const { stubInput } = vi.hoisted(() => {
|
||||
const stubInput =
|
||||
(name: string) =>
|
||||
({ password }: { password?: boolean }) => (
|
||||
<div data-input={name} data-password={password ? 'on' : 'off'} />
|
||||
)
|
||||
return { stubInput }
|
||||
})
|
||||
|
||||
vi.mock('@sim/emcn', () => ({
|
||||
Button: ({ children }: { children?: ReactNode }) => <button type='button'>{children}</button>,
|
||||
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
|
||||
Input: () => null,
|
||||
Label: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
|
||||
Tooltip: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/emcn/icons', () => ({
|
||||
ArrowLeftRight: () => null,
|
||||
ArrowUp: () => null,
|
||||
Check: () => null,
|
||||
Clipboard: () => null,
|
||||
SquareArrowUpRight: () => null,
|
||||
TriangleAlert: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ workspaceId: 'workspace-1' }),
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components',
|
||||
() => ({
|
||||
CheckboxList: stubInput('checkbox-list'),
|
||||
Code: stubInput('code'),
|
||||
ComboBox: stubInput('combobox'),
|
||||
ConditionInput: stubInput('condition-input'),
|
||||
CredentialSelector: stubInput('credential-selector'),
|
||||
DocumentTagEntry: stubInput('document-tag-entry'),
|
||||
Dropdown: stubInput('dropdown'),
|
||||
EvalInput: stubInput('eval-input'),
|
||||
FileUpload: stubInput('file-upload'),
|
||||
FilterBuilder: stubInput('filter-builder'),
|
||||
GroupedCheckboxList: stubInput('grouped-checkbox-list'),
|
||||
InputFormat: stubInput('input-format'),
|
||||
InputMapping: stubInput('input-mapping'),
|
||||
KnowledgeBaseSelector: stubInput('knowledge-base-selector'),
|
||||
KnowledgeTagFilters: stubInput('knowledge-tag-filters'),
|
||||
LongInput: stubInput('long-input'),
|
||||
McpDynamicArgs: stubInput('mcp-dynamic-args'),
|
||||
McpServerSelector: stubInput('mcp-server-selector'),
|
||||
McpToolSelector: stubInput('mcp-tool-selector'),
|
||||
MessagesInput: stubInput('messages-input'),
|
||||
ResponseFormat: stubInput('response-format'),
|
||||
ScheduleInfo: stubInput('schedule-info'),
|
||||
SelectorInput: stubInput('selector-input'),
|
||||
ShortInput: stubInput('short-input'),
|
||||
SkillInput: stubInput('skill-input'),
|
||||
SliderInput: stubInput('slider-input'),
|
||||
SortBuilder: stubInput('sort-builder'),
|
||||
Switch: stubInput('switch'),
|
||||
Table: stubInput('table'),
|
||||
TableSelector: stubInput('table-selector'),
|
||||
Text: stubInput('text'),
|
||||
TimeInput: stubInput('time-input'),
|
||||
ToolInput: stubInput('tool-input'),
|
||||
VariablesInput: stubInput('variables-input'),
|
||||
WorkflowSelectorInput: stubInput('workflow-selector'),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/modal-registry',
|
||||
() => ({ MODAL_REGISTRY: {} })
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate',
|
||||
() => ({ useDependsOnGate: () => ({ finalDisabled: false }) })
|
||||
)
|
||||
|
||||
vi.mock('@/hooks/use-webhook-management', () => ({
|
||||
useWebhookManagement: () => ({ webhookUrl: null }),
|
||||
}))
|
||||
|
||||
import { PASSWORD_MASKED_SUBBLOCK_TYPES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/password-mask'
|
||||
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
function renderSubBlock(config: SubBlockConfig) {
|
||||
return renderToStaticMarkup(<SubBlock blockId='block-1' config={config} />)
|
||||
}
|
||||
|
||||
describe('SubBlock password forwarding', () => {
|
||||
it.each(PASSWORD_MASKED_SUBBLOCK_TYPES)('forwards password to the %s renderer', (type) => {
|
||||
const html = renderSubBlock({
|
||||
id: 'secret',
|
||||
title: 'Secret',
|
||||
type,
|
||||
password: true,
|
||||
columns: ['Key', 'Value'],
|
||||
})
|
||||
|
||||
expect(html).toContain('data-password="on"')
|
||||
})
|
||||
|
||||
it.each(PASSWORD_MASKED_SUBBLOCK_TYPES)('leaves the %s renderer unmasked by default', (type) => {
|
||||
const html = renderSubBlock({
|
||||
id: 'secret',
|
||||
title: 'Secret',
|
||||
type,
|
||||
columns: ['Key', 'Value'],
|
||||
})
|
||||
|
||||
expect(html).toContain('data-password="off"')
|
||||
})
|
||||
})
|
||||
+3
-7
@@ -106,7 +106,6 @@ interface SubBlockProps {
|
||||
labelSuffix?: React.ReactNode
|
||||
/** Provides sibling values for dependency resolution in non-preview contexts (e.g. tool-input) */
|
||||
dependencyContext?: Record<string, unknown>
|
||||
isSearchHighlighted?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,7 +232,6 @@ const renderLabel = (
|
||||
onCopy: () => void
|
||||
},
|
||||
labelSuffix?: React.ReactNode,
|
||||
_isSearchHighlighted?: boolean,
|
||||
externalLink?: {
|
||||
show: boolean
|
||||
onClick: () => void
|
||||
@@ -441,7 +439,6 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
|
||||
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
|
||||
canonicalToggleEqual &&
|
||||
prevProps.labelSuffix === nextProps.labelSuffix &&
|
||||
prevProps.isSearchHighlighted === nextProps.isSearchHighlighted &&
|
||||
prevProps.dependencyContext === nextProps.dependencyContext
|
||||
)
|
||||
}
|
||||
@@ -458,7 +455,6 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
|
||||
* @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle
|
||||
* @param labelSuffix - Additional content rendered after the label text
|
||||
* @param dependencyContext - Sibling values for dependency resolution in non-preview contexts (e.g. tool-input)
|
||||
* @param isSearchHighlighted - Whether workflow search should highlight this field
|
||||
*/
|
||||
function SubBlockComponent({
|
||||
blockId,
|
||||
@@ -470,7 +466,6 @@ function SubBlockComponent({
|
||||
canonicalToggle,
|
||||
labelSuffix,
|
||||
dependencyContext,
|
||||
isSearchHighlighted,
|
||||
}: SubBlockProps): JSX.Element {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -652,7 +647,6 @@ function SubBlockComponent({
|
||||
disabled={isDisabled}
|
||||
wandControlRef={wandControlRef}
|
||||
hideInternalWand={true}
|
||||
isSearchHighlighted={isSearchHighlighted}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -662,6 +656,7 @@ function SubBlockComponent({
|
||||
blockId={blockId}
|
||||
subBlockId={config.id}
|
||||
placeholder={config.placeholder}
|
||||
password={config.password}
|
||||
rows={config.rows}
|
||||
config={config}
|
||||
isPreview={isPreview}
|
||||
@@ -749,6 +744,7 @@ function SubBlockComponent({
|
||||
blockId={blockId}
|
||||
subBlockId={config.id}
|
||||
columns={config.columns ?? []}
|
||||
password={config.password}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue as any}
|
||||
disabled={isDisabled}
|
||||
@@ -761,6 +757,7 @@ function SubBlockComponent({
|
||||
blockId={blockId}
|
||||
subBlockId={config.id}
|
||||
placeholder={config.placeholder}
|
||||
password={config.password}
|
||||
language={config.language}
|
||||
generationType={config.generationType}
|
||||
value={
|
||||
@@ -1211,7 +1208,6 @@ function SubBlockComponent({
|
||||
onCopy: handleCopy,
|
||||
},
|
||||
labelSuffix,
|
||||
false,
|
||||
externalLink
|
||||
)}
|
||||
{renderInput()}
|
||||
|
||||
-12
@@ -667,12 +667,6 @@ export function Editor() {
|
||||
subBlockValues={subBlockState}
|
||||
disabled={!canEditBlock}
|
||||
allowExpandInPreview={false}
|
||||
isSearchHighlighted={
|
||||
activeSearchTarget?.blockId === currentBlockId &&
|
||||
(activeSearchTarget.subBlockId === subBlock.id ||
|
||||
activeSearchTarget.canonicalSubBlockId ===
|
||||
(subBlock.canonicalParamId ?? subBlock.id))
|
||||
}
|
||||
canonicalToggle={
|
||||
isCanonicalSwap && canonicalMode && canonicalId
|
||||
? {
|
||||
@@ -750,12 +744,6 @@ export function Editor() {
|
||||
subBlockValues={subBlockState}
|
||||
disabled={!canEditBlock}
|
||||
allowExpandInPreview={false}
|
||||
isSearchHighlighted={
|
||||
activeSearchTarget?.blockId === currentBlockId &&
|
||||
(activeSearchTarget.subBlockId === subBlock.id ||
|
||||
activeSearchTarget.canonicalSubBlockId ===
|
||||
(subBlock.canonicalParamId ?? subBlock.id))
|
||||
}
|
||||
/>
|
||||
{(index < advancedOnlySubBlocks.length - 1 || showRetrySettings) && (
|
||||
<FieldDivider
|
||||
|
||||
@@ -43,7 +43,11 @@ const SUBBLOCK_ALIASES: Record<string, Record<string, string>> = {
|
||||
create_ruleset: { name: 'rulesetName' },
|
||||
update_ruleset_rule: { enabled: 'updateRuleEnabled' },
|
||||
create_rate_limit_rule: { action: 'rateLimitAction' },
|
||||
update_rate_limit_rule: { action: 'updateRateLimitAction', enabled: 'updateRuleEnabled' },
|
||||
update_rate_limit_rule: {
|
||||
action: 'updateRateLimitAction',
|
||||
enabled: 'updateRuleEnabled',
|
||||
actionParameters: 'rateLimitActionParameters',
|
||||
},
|
||||
create_access_application: { type: 'appType', tags: 'accessAppTags' },
|
||||
update_access_application: { type: 'updateAppType', tags: 'accessAppTags' },
|
||||
update_access_policy: { decision: 'updatePolicyDecision' },
|
||||
@@ -121,7 +125,10 @@ export const CloudflareBlock: BlockConfig<CloudflareResponse> = {
|
||||
{ text: 'List certificate packs for zone', field: 'zoneId', core: true },
|
||||
{ text: ', with status', field: 'certificateStatus' },
|
||||
],
|
||||
get_zone_settings: [{ text: 'Read all settings of zone', field: 'zoneId', core: true }],
|
||||
get_zone_settings: [
|
||||
{ text: 'Read settings of zone', field: 'zoneId', core: true },
|
||||
{ text: ', limited to', field: 'settingIds' },
|
||||
],
|
||||
update_zone_setting: [
|
||||
{ text: 'Set', field: 'settingId', core: true },
|
||||
{ text: 'to', field: 'value' },
|
||||
@@ -672,7 +679,7 @@ export const CloudflareBlock: BlockConfig<CloudflareResponse> = {
|
||||
id: 'priority',
|
||||
title: 'Priority',
|
||||
type: 'short-input',
|
||||
placeholder: 'MX/SRV priority (e.g., 10)',
|
||||
placeholder: 'MX/URI priority (e.g., 10)',
|
||||
condition: { field: 'operation', value: 'create_dns_record' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
@@ -774,7 +781,7 @@ export const CloudflareBlock: BlockConfig<CloudflareResponse> = {
|
||||
id: 'priority',
|
||||
title: 'Priority',
|
||||
type: 'short-input',
|
||||
placeholder: 'MX/SRV priority (e.g., 10)',
|
||||
placeholder: 'MX/URI priority (e.g., 10)',
|
||||
condition: { field: 'operation', value: 'update_dns_record' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
@@ -873,6 +880,19 @@ export const CloudflareBlock: BlockConfig<CloudflareResponse> = {
|
||||
placeholder: 'Enter zone ID',
|
||||
condition: { field: 'operation', value: 'get_zone_settings' },
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Cloudflare retired the endpoint that read every setting in one request,
|
||||
* so this operation reads one setting per request. Naming the settings
|
||||
* keeps the fan-out to what the workflow actually reads.
|
||||
*/
|
||||
id: 'settingIds',
|
||||
title: 'Settings',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated setting IDs (blank reads the default set)',
|
||||
condition: { field: 'operation', value: 'get_zone_settings' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
|
||||
// Update Zone Setting inputs
|
||||
{
|
||||
@@ -1001,7 +1021,6 @@ Return ONLY the timestamp or relative expression - no explanations, no quotes, n
|
||||
title: 'Metrics',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated (e.g., queryCount,uncachedCount,responseTimeAvg)',
|
||||
required: { field: 'operation', value: 'dns_analytics' },
|
||||
condition: { field: 'operation', value: 'dns_analytics' },
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
@@ -1126,7 +1145,7 @@ Return ONLY the filter expression - no explanations, no quotes, no extra text.`,
|
||||
{ label: 'Yes - Purge All', id: 'true' },
|
||||
{ label: 'No - Purge Specific', id: 'false' },
|
||||
],
|
||||
value: () => 'true',
|
||||
value: () => 'false',
|
||||
condition: { field: 'operation', value: 'purge_cache' },
|
||||
},
|
||||
{
|
||||
@@ -1135,7 +1154,11 @@ Return ONLY the filter expression - no explanations, no quotes, no extra text.`,
|
||||
type: 'long-input',
|
||||
placeholder:
|
||||
'Comma-separated URLs (e.g., https://example.com/style.css, https://example.com/app.js)',
|
||||
condition: { field: 'operation', value: 'purge_cache' },
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'purge_cache',
|
||||
and: { field: 'purge_everything', value: 'true', not: true },
|
||||
},
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Generate a comma-separated list of URLs to purge from Cloudflare's cache based on the user's description.
|
||||
@@ -1155,7 +1178,11 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`,
|
||||
title: 'Cache Tags',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated cache tags (Enterprise only)',
|
||||
condition: { field: 'operation', value: 'purge_cache' },
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'purge_cache',
|
||||
and: { field: 'purge_everything', value: 'true', not: true },
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
@@ -1163,7 +1190,11 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`,
|
||||
title: 'Hostnames',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated hostnames (Enterprise only)',
|
||||
condition: { field: 'operation', value: 'purge_cache' },
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'purge_cache',
|
||||
and: { field: 'purge_everything', value: 'true', not: true },
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
@@ -1171,7 +1202,11 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`,
|
||||
title: 'URL Prefixes',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated URL prefixes (Enterprise only)',
|
||||
condition: { field: 'operation', value: 'purge_cache' },
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'purge_cache',
|
||||
and: { field: 'purge_everything', value: 'true', not: true },
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
|
||||
@@ -1503,7 +1538,7 @@ Return ONLY the expression - no explanations, no quotes around the whole express
|
||||
placeholder: 'Stable reference that survives rule updates',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['create_ruleset_rule', 'update_ruleset_rule'],
|
||||
value: ['create_ruleset_rule', 'update_ruleset_rule', 'update_rate_limit_rule'],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
@@ -1521,7 +1556,10 @@ Return ONLY the expression - no explanations, no quotes around the whole express
|
||||
title: 'Logging Configuration',
|
||||
type: 'long-input',
|
||||
placeholder: '{"enabled":true}',
|
||||
condition: { field: 'operation', value: 'update_ruleset_rule' },
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['update_ruleset_rule', 'update_rate_limit_rule'],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
@@ -1591,7 +1629,7 @@ Return ONLY the JSON object - no explanations, no markdown fences.`,
|
||||
enabled: true,
|
||||
prompt: `Generate a comma-separated list of Cloudflare rate limiting counting characteristics from the user's description.
|
||||
|
||||
cf.colo.id is mandatory in every list. Include exactly one of ip.src or cf.unique_visitor_id.
|
||||
cf.colo.id is mandatory in every list. ip.src and cf.unique_visitor_id are mutually exclusive - include at most one, and neither is required. Do not add an IP or visitor characteristic the user did not ask for; a rule keyed on host, path, country, header, cookie or JA3/JA4 alone is valid.
|
||||
|
||||
Available characteristics:
|
||||
- cf.colo.id (mandatory)
|
||||
@@ -1610,7 +1648,7 @@ Available characteristics:
|
||||
Examples:
|
||||
- "per IP address" -> cf.colo.id,ip.src
|
||||
- "per visitor" -> cf.colo.id,cf.unique_visitor_id
|
||||
- "per API key header" -> cf.colo.id,ip.src,http.request.headers["x-api-key"]
|
||||
- "per API key header" -> cf.colo.id,http.request.headers["x-api-key"]
|
||||
- "per country" -> cf.colo.id,ip.src.country
|
||||
|
||||
Return ONLY the comma-separated list - no explanations, no extra text.`,
|
||||
@@ -1703,6 +1741,39 @@ Return ONLY the comma-separated list - no explanations, no extra text.`,
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
/**
|
||||
* A rate limiting rule carries its custom mitigation response here, and
|
||||
* the update endpoint replaces the rule — so leaving this blank resets
|
||||
* action_parameters to {} and the rule falls back to Cloudflare's default
|
||||
* block page. It gets its own id because the WAF control of the same name
|
||||
* holds a managed-ruleset payload, which is not what this rule takes.
|
||||
*/
|
||||
id: 'rateLimitActionParameters',
|
||||
title: 'Action Parameters',
|
||||
type: 'long-input',
|
||||
placeholder:
|
||||
'{"response":{"status_code":429,"content":"{\\"error\\":\\"rate limited\\"}","content_type":"application/json"}}',
|
||||
condition: { field: 'operation', value: 'update_rate_limit_rule' },
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Generate the JSON action_parameters object for a Cloudflare rate limiting rule from the user's description.
|
||||
|
||||
Only a "block" action takes action_parameters, and only to define a custom response:
|
||||
{"response":{"status_code":429,"content":"You have been rate limited.","content_type":"text/plain"}}
|
||||
|
||||
status_code must be in the 400-499 range. content_type is one of "text/plain", "text/html", or "application/json". For a JSON body, "content" is the JSON payload as a string:
|
||||
{"response":{"status_code":429,"content":"{\\"error\\":\\"rate limited\\"}","content_type":"application/json"}}
|
||||
|
||||
Challenge and log actions take no action_parameters - return {} for those.
|
||||
|
||||
Return ONLY the JSON object - no explanations, no markdown fences.`,
|
||||
placeholder:
|
||||
'Describe the mitigation response (e.g., "return a 429 with a JSON error body")...',
|
||||
generationType: 'json-object',
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
|
||||
// Access application inputs
|
||||
{
|
||||
@@ -2556,12 +2627,6 @@ Return ONLY the JSON array - no explanations, no markdown fences.`,
|
||||
result[aliasId] = undefined
|
||||
}
|
||||
|
||||
if (result.ttl) result.ttl = Number(result.ttl)
|
||||
if (result.priority) result.priority = Number(result.priority)
|
||||
if (result.limit) result.limit = Number(result.limit)
|
||||
if (result.page) result.page = Number(result.page)
|
||||
if (result.per_page) result.per_page = Number(result.per_page)
|
||||
|
||||
if (result.proxied === 'true') result.proxied = true
|
||||
else if (result.proxied === 'false') result.proxied = false
|
||||
else if (result.proxied === '') result.proxied = undefined
|
||||
@@ -2569,6 +2634,23 @@ Return ONLY the JSON array - no explanations, no markdown fences.`,
|
||||
if (result.purge_everything === 'true') result.purge_everything = true
|
||||
else if (result.purge_everything === 'false') result.purge_everything = false
|
||||
|
||||
/**
|
||||
* `tags`, `hosts`, and `prefixes` are advanced controls, and an advanced
|
||||
* control serializes on stored value alone — the serializer returns
|
||||
* `isNonEmptyValue(...)` before it ever evaluates the
|
||||
* `and: { field: 'purge_everything', not: true }` guard
|
||||
* (`serializer/index.ts`). So a target typed while purging specific
|
||||
* content survives the switch to "Purge Everything", and the tool then
|
||||
* refuses the whole purge over a field the editor no longer renders.
|
||||
* This mapper is the only layer that can override a stale raw input.
|
||||
*/
|
||||
if (operation === 'purge_cache' && result.purge_everything === true) {
|
||||
result.files = undefined
|
||||
result.tags = undefined
|
||||
result.hosts = undefined
|
||||
result.prefixes = undefined
|
||||
}
|
||||
|
||||
if (result.type === '') result.type = undefined
|
||||
if (result.status === '') result.status = undefined
|
||||
if (result.order === '') result.order = undefined
|
||||
@@ -2583,14 +2665,25 @@ Return ONLY the JSON array - no explanations, no markdown fences.`,
|
||||
if (result.comment === '') result.comment = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A blank optional number must reach the tool as `undefined`, not as
|
||||
* `Number('')` — which is `0`, a value the tools then forward because
|
||||
* they test presence rather than truthiness. `0` is out of range for
|
||||
* `ttl` and silently rewrites an MX record's `priority`.
|
||||
*/
|
||||
const numericFields = [
|
||||
'ttl',
|
||||
'priority',
|
||||
'limit',
|
||||
'page',
|
||||
'per_page',
|
||||
'period',
|
||||
'requestsPerPeriod',
|
||||
'mitigationTimeout',
|
||||
'precedence',
|
||||
] as const
|
||||
for (const field of numericFields) {
|
||||
if (result[field] === '' || result[field] === undefined) {
|
||||
if (result[field] === '' || result[field] == null) {
|
||||
result[field] = undefined
|
||||
} else {
|
||||
result[field] = Number(result[field])
|
||||
@@ -2634,7 +2727,11 @@ Return ONLY the JSON array - no explanations, no markdown fences.`,
|
||||
apiKey: { type: 'string', description: 'Cloudflare API token' },
|
||||
zoneId: { type: 'string', description: 'Zone ID' },
|
||||
accountId: { type: 'string', description: 'Cloudflare account ID' },
|
||||
zoneType: { type: 'string', description: 'Zone type (full, partial, or secondary)' },
|
||||
zoneType: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Zone type to create (full, partial, or secondary). Cloudflare also defines an internal type, which is not creatable here but can appear on zones returned by reads',
|
||||
},
|
||||
order: { type: 'string', description: 'Sort field when listing zones' },
|
||||
direction: { type: 'string', description: 'Sort direction (asc, desc)' },
|
||||
match: { type: 'string', description: 'Match logic for filters (any, all)' },
|
||||
@@ -2705,13 +2802,21 @@ Return ONLY the JSON array - no explanations, no markdown fences.`,
|
||||
content: { type: 'string', description: 'DNS record content' },
|
||||
ttl: { type: 'number', description: 'Time to live in seconds' },
|
||||
proxied: { type: 'boolean', description: 'Whether Cloudflare proxy is enabled' },
|
||||
priority: { type: 'number', description: 'Record priority (MX/SRV)' },
|
||||
priority: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Record priority. Cloudflare accepts this top-level field for MX and URI records only; an SRV record carries its priority inside the record content instead',
|
||||
},
|
||||
comment: { type: 'string', description: 'Record comment' },
|
||||
search: { type: 'string', description: 'Free-text search across record properties' },
|
||||
tag: { type: 'string', description: 'Filter by an exact tag name' },
|
||||
tag_match: { type: 'string', description: 'Tag filter match logic (any, all)' },
|
||||
commentFilter: { type: 'string', description: 'Filter records by comment content' },
|
||||
settingId: { type: 'string', description: 'Zone setting ID' },
|
||||
settingIds: {
|
||||
type: 'string',
|
||||
description: 'Comma-separated zone setting IDs to read, or blank for the default set',
|
||||
},
|
||||
value: { type: 'string', description: 'Setting value' },
|
||||
since: { type: 'string', description: 'Start date for analytics' },
|
||||
until: { type: 'string', description: 'End date for analytics' },
|
||||
@@ -2747,6 +2852,10 @@ Return ONLY the JSON array - no explanations, no markdown fences.`,
|
||||
enabled: { type: 'boolean', description: 'Whether the rule is enabled' },
|
||||
ref: { type: 'string', description: 'Rule reference tag' },
|
||||
actionParameters: { type: 'string', description: 'JSON action parameters for a rule' },
|
||||
rateLimitActionParameters: {
|
||||
type: 'string',
|
||||
description: 'JSON action parameters a replaced rate limiting rule ends up with',
|
||||
},
|
||||
ratelimit: {
|
||||
type: 'string',
|
||||
description: 'JSON rate limiting configuration to preserve when replacing a rule',
|
||||
@@ -2855,9 +2964,21 @@ Return ONLY the JSON array - no explanations, no markdown fences.`,
|
||||
records: { type: 'json', description: 'List of DNS records' },
|
||||
certificates: { type: 'json', description: 'List of SSL/TLS certificate packs' },
|
||||
settings: { type: 'json', description: 'List of zone settings' },
|
||||
unreadable: {
|
||||
type: 'json',
|
||||
description: 'Requested zone settings Cloudflare refused, with the reason for each',
|
||||
},
|
||||
totals: { type: 'json', description: 'Aggregate DNS analytics totals' },
|
||||
min: { type: 'json', description: 'Minimum values across the DNS analytics period' },
|
||||
max: { type: 'json', description: 'Maximum values across the DNS analytics period' },
|
||||
min: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Per-metric DNS analytics minimums. Cloudflare documents this as currently always an empty object',
|
||||
},
|
||||
max: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Per-metric DNS analytics maximums. Cloudflare documents this as currently always an empty object',
|
||||
},
|
||||
query: { type: 'json', description: 'Echo of the DNS analytics query parameters sent' },
|
||||
validation_errors: { type: 'json', description: 'Validation issues for certificate packs' },
|
||||
data: { type: 'json', description: 'Raw analytics data rows from the DNS analytics report' },
|
||||
@@ -2898,7 +3019,7 @@ Return ONLY the JSON array - no explanations, no markdown fences.`,
|
||||
proxied: { type: 'boolean', description: 'Whether Cloudflare proxy is enabled' },
|
||||
ttl: { type: 'number', description: 'TTL in seconds (1 = automatic)' },
|
||||
locked: { type: 'boolean', description: 'Whether the record is locked' },
|
||||
priority: { type: 'number', description: 'Priority for MX and SRV records' },
|
||||
priority: { type: 'number', description: 'Record priority, returned for MX and URI records' },
|
||||
comment: { type: 'string', description: 'Record comment' },
|
||||
tags: { type: 'json', description: 'Tags associated with the record or cache tags to purge' },
|
||||
comment_modified_on: {
|
||||
@@ -2913,7 +3034,11 @@ Return ONLY the JSON array - no explanations, no markdown fences.`,
|
||||
modified_on: { type: 'string', description: 'Last modified date (ISO 8601)' },
|
||||
value: { type: 'string', description: 'Setting value (complex values are JSON-stringified)' },
|
||||
editable: { type: 'boolean', description: 'Whether the setting can be modified' },
|
||||
time_remaining: { type: 'number', description: 'Seconds until setting can be modified again' },
|
||||
time_remaining: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Development mode countdown in seconds — documented only on the zones_development_mode setting, positive until it expires and negative afterwards',
|
||||
},
|
||||
total_count: { type: 'number', description: 'Total count of results' },
|
||||
rulesets: { type: 'json', description: 'Rulesets defined on the zone' },
|
||||
rules: { type: 'json', description: 'Rules contained in a ruleset, in evaluation order' },
|
||||
@@ -3179,7 +3304,7 @@ export const CloudflareBlockMeta = {
|
||||
description:
|
||||
'Protect an API path from abuse with a Cloudflare rate limiting rule using the current Rulesets-based rate limiting API.',
|
||||
content:
|
||||
'# Rate Limit an API Endpoint\n\nRate limiting rules are rules in the `http_ratelimit` phase entry point ruleset. The legacy `rate_limits` endpoint is no longer the way to do this.\n\n## Steps\n1. Resolve the zone ID for the domain serving the API.\n2. List the existing rate limiting rules to get the `http_ratelimit` entry point ruleset ID and see what is already in place.\n3. Decide the counting characteristics. `cf.colo.id` is mandatory, plus exactly one of `ip.src` (per IP) or `cf.unique_visitor_id` (per visitor); add `http.request.headers["<name>"]` to count per API key.\n4. Pick a counting period (10, 60, 120, 300, 600, or 3600 seconds) and the request allowance for that period.\n5. Create the rule with the matching expression (e.g. `(http.request.uri.path matches "^/api/")`), the counting configuration, and the mitigation action.\n6. Read the rules back and confirm the new rule and its limit.\n\n## Output\nThe ruleset ID, the new rule ID, the expression, and the effective limit (requests per period, characteristics, and mitigation timeout).\n\n## Cautions\nThe rule applies to live traffic as soon as it is created. Size the allowance against real traffic before choosing `block` over `log` or `managed_challenge`.',
|
||||
'# Rate Limit an API Endpoint\n\nRate limiting rules are rules in the `http_ratelimit` phase entry point ruleset. The legacy `rate_limits` endpoint is no longer the way to do this.\n\n## Steps\n1. Resolve the zone ID for the domain serving the API.\n2. List the existing rate limiting rules to get the `http_ratelimit` entry point ruleset ID and see what is already in place.\n3. Decide the counting characteristics. `cf.colo.id` is mandatory. `ip.src` (per IP) and `cf.unique_visitor_id` (per visitor) are mutually exclusive - include at most one, and neither is required; a rule keyed on host, path, country, header or cookie alone is valid. Add `http.request.headers["<name>"]` to count per API key.\n4. Pick a counting period (10, 60, 120, 300, 600, or 3600 seconds) and the request allowance for that period.\n5. Create the rule with the matching expression (e.g. `(http.request.uri.path matches "^/api/")`), the counting configuration, and the mitigation action.\n6. Read the rules back and confirm the new rule and its limit.\n\n## Output\nThe ruleset ID, the new rule ID, the expression, and the effective limit (requests per period, characteristics, and mitigation timeout).\n\n## Cautions\nThe rule applies to live traffic as soon as it is created. Size the allowance against real traffic before choosing `block` over `log` or `managed_challenge`.',
|
||||
},
|
||||
{
|
||||
name: 'review-zero-trust-access',
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries'
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason: unknown) => void
|
||||
}
|
||||
|
||||
const { requestJsonMock, capturedSignals, pending, getTestQueryClient, setTestQueryClient } =
|
||||
vi.hoisted(() => {
|
||||
const capturedSignals: Array<AbortSignal | undefined> = []
|
||||
const pending: PendingRequest[] = []
|
||||
let client: unknown = null
|
||||
return {
|
||||
capturedSignals,
|
||||
pending,
|
||||
getTestQueryClient: () => client,
|
||||
setTestQueryClient: (next: unknown) => {
|
||||
client = next
|
||||
},
|
||||
requestJsonMock: vi.fn((_contract: unknown, input: { signal?: AbortSignal }) => {
|
||||
capturedSignals.push(input.signal)
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.push({ resolve, reject })
|
||||
input.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'))
|
||||
})
|
||||
})
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/api/client/request', () => ({ requestJson: requestJsonMock }))
|
||||
|
||||
vi.mock('@/app/_shell/providers/get-query-client', () => ({
|
||||
getQueryClient: () => getTestQueryClient(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflows/registry/store', () => ({
|
||||
useWorkflowRegistry: {
|
||||
getState: () => ({ hydration: { workspaceId: 'workspace-1' }, activeWorkflowId: null }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflows/subblock/store', () => ({
|
||||
useSubBlockStore: { getState: () => ({ workflowValues: {} }) },
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflows/workflow/store', () => ({
|
||||
useWorkflowStore: { getState: () => ({ blocks: {} }) },
|
||||
}))
|
||||
|
||||
import { CredentialGroupBlock } from '@/blocks/blocks/credential-group'
|
||||
|
||||
const WORKSPACE_LIST_KEY = credentialGroupKeys.list('workspace-1')
|
||||
|
||||
const GROUPS = [
|
||||
{
|
||||
id: 'group-1',
|
||||
name: 'Support accounts',
|
||||
status: 'active',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
function getCredentialGroupSubBlock(): SubBlockConfig {
|
||||
const subBlock = CredentialGroupBlock.subBlocks.find((entry) => entry.id === 'credentialGroup')
|
||||
if (!subBlock) throw new Error('credentialGroup subBlock is missing')
|
||||
return subBlock
|
||||
}
|
||||
|
||||
async function waitForRequestCount(count: number) {
|
||||
await vi.waitFor(() => expect(capturedSignals.length).toBe(count), { interval: 1, timeout: 1000 })
|
||||
}
|
||||
|
||||
describe('credential group dynamic option resolution', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
capturedSignals.length = 0
|
||||
pending.length = 0
|
||||
requestJsonMock.mockClear()
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
setTestQueryClient(queryClient)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('keeps the shared workspace credential-group list alive when one option resolution is cancelled', async () => {
|
||||
const subBlock = getCredentialGroupSubBlock()
|
||||
const fetchOptionById = subBlock.fetchOptionById
|
||||
const fetchOptions = subBlock.fetchOptions
|
||||
if (!fetchOptionById || !fetchOptions) throw new Error('credentialGroup resolvers are missing')
|
||||
|
||||
const controller = new AbortController()
|
||||
const optionResolution = Promise.resolve(
|
||||
fetchOptionById('block-1', 'group-1', controller.signal)
|
||||
).catch(() => null)
|
||||
|
||||
await waitForRequestCount(1)
|
||||
|
||||
const sharedListConsumer = fetchOptions('block-1')
|
||||
|
||||
controller.abort()
|
||||
await Promise.resolve()
|
||||
|
||||
pending[0].resolve({ credentialGroups: GROUPS })
|
||||
|
||||
await expect(sharedListConsumer).resolves.toEqual([
|
||||
{ label: 'Support accounts', id: 'group-1' },
|
||||
])
|
||||
expect(queryClient.getQueryState(WORKSPACE_LIST_KEY)?.status).not.toBe('error')
|
||||
|
||||
await optionResolution
|
||||
})
|
||||
|
||||
it('still cancels the underlying request when React Query cancels the shared list query', async () => {
|
||||
const subBlock = getCredentialGroupSubBlock()
|
||||
const fetchOptionById = subBlock.fetchOptionById
|
||||
if (!fetchOptionById) throw new Error('credentialGroup fetchOptionById is missing')
|
||||
|
||||
const controller = new AbortController()
|
||||
const optionResolution = Promise.resolve(
|
||||
fetchOptionById('block-1', 'group-1', controller.signal)
|
||||
).catch(() => null)
|
||||
|
||||
await waitForRequestCount(1)
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: WORKSPACE_LIST_KEY })
|
||||
|
||||
expect(capturedSignals[0]?.aborted).toBe(true)
|
||||
|
||||
await optionResolution
|
||||
})
|
||||
})
|
||||
@@ -21,14 +21,19 @@ const CREDENTIAL_GROUP_CANONICAL_GROUP = {
|
||||
advancedIds: ['manualCredentialGroup'],
|
||||
} as const satisfies CanonicalGroup
|
||||
|
||||
async function fetchCachedCredentialGroups(signal?: AbortSignal) {
|
||||
/**
|
||||
* Reads the workspace credential-group list through the shared cache entry every
|
||||
* consumer observes. The fetch stays bound to React Query's own signal: a caller's
|
||||
* signal belongs to that caller alone, and forwarding it here would abort a request
|
||||
* other observers of this workspace-wide key are awaiting.
|
||||
*/
|
||||
async function fetchCachedCredentialGroups() {
|
||||
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
|
||||
if (!workspaceId) return []
|
||||
|
||||
return getQueryClient().fetchQuery({
|
||||
queryKey: credentialGroupKeys.list(workspaceId),
|
||||
queryFn: ({ signal: querySignal }) =>
|
||||
fetchCredentialGroupList(workspaceId, signal ?? querySignal),
|
||||
queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal),
|
||||
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
@@ -170,8 +175,8 @@ export const CredentialGroupBlock: BlockConfig<CredentialGroupBlockOutput> = {
|
||||
.map((group) => ({ label: group.name, id: group.id }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
},
|
||||
fetchOptionById: async (_blockId: string, optionId: string, signal?: AbortSignal) => {
|
||||
const groups = await fetchCachedCredentialGroups(signal)
|
||||
fetchOptionById: async (_blockId: string, optionId: string) => {
|
||||
const groups = await fetchCachedCredentialGroups()
|
||||
const group = groups.find((candidate) => candidate.id === optionId)
|
||||
return group ? { label: group.name, id: group.id } : null
|
||||
},
|
||||
@@ -220,10 +225,10 @@ export const CredentialGroupBlock: BlockConfig<CredentialGroupBlockOutput> = {
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
},
|
||||
fetchOptionById: async (blockId: string, optionId: string, signal?: AbortSignal) => {
|
||||
fetchOptionById: async (blockId: string, optionId: string) => {
|
||||
const credentialGroupId = resolveCredentialGroupIdForBlock(blockId)
|
||||
if (!credentialGroupId) return null
|
||||
const groups = await fetchCachedCredentialGroups(signal)
|
||||
const groups = await fetchCachedCredentialGroups()
|
||||
const group = groups.find((candidate) => candidate.id === credentialGroupId)
|
||||
const option = group?.options.find(
|
||||
(candidate) =>
|
||||
|
||||
@@ -196,6 +196,49 @@ describe('CrowdStrike block params', () => {
|
||||
expect(ids).toContain('detection_unsuppress')
|
||||
})
|
||||
|
||||
/**
|
||||
* CrowdStrike declares `include_hidden` with `"default": true` on every alert
|
||||
* endpoint this switch feeds, so omitting the parameter still returns hidden
|
||||
* alerts. A switch that renders off while the wire behaves as on tells the
|
||||
* analyst the opposite of what Falcon does.
|
||||
*/
|
||||
it('seeds the hidden-alert switch on, matching the CrowdStrike default', () => {
|
||||
const includeHidden = CrowdStrikeBlock.subBlocks.find(
|
||||
(subBlock) => subBlock.id === 'includeHidden'
|
||||
)
|
||||
|
||||
expect(includeHidden?.value?.({})).toBe('true')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['crowdstrike_query_alerts', {}],
|
||||
['crowdstrike_get_alert_details', { compositeIds: '["cid:aid:alert"]' }],
|
||||
['crowdstrike_update_alerts', { compositeIds: '["cid:aid:alert"]', updateStatus: 'closed' }],
|
||||
])('sends the seeded hidden-alert switch as an explicit true for %s', (operation, extra) => {
|
||||
const includeHidden = CrowdStrikeBlock.subBlocks.find(
|
||||
(subBlock) => subBlock.id === 'includeHidden'
|
||||
)
|
||||
|
||||
const merged = merge({
|
||||
...credentials,
|
||||
...extra,
|
||||
operation,
|
||||
includeHidden: includeHidden?.value?.({}),
|
||||
})
|
||||
|
||||
expect(merged.includeHidden).toBe(true)
|
||||
})
|
||||
|
||||
it('still sends false when the analyst turns the hidden-alert switch off', () => {
|
||||
const merged = merge({
|
||||
...credentials,
|
||||
operation: 'crowdstrike_query_alerts',
|
||||
includeHidden: false,
|
||||
})
|
||||
|
||||
expect(merged.includeHidden).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps every tool description inside the docs generator id-search window', () => {
|
||||
const toolsDir = path.join(__dirname, '../../tools/crowdstrike')
|
||||
const offenders: string[] = []
|
||||
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
} from '@/blocks/utils'
|
||||
import type { CrowdStrikeResponse } from '@/tools/crowdstrike/types'
|
||||
|
||||
/** Documented maximum `limit` for each CrowdStrike query collection. */
|
||||
/**
|
||||
* Maximum `limit` for each CrowdStrike query collection. Every entry except IOC
|
||||
* Management is the `maximum` CrowdStrike publishes in its swagger. The IOC
|
||||
* indicators endpoint publishes no `maximum` at all, so 500 is a Sim cap chosen
|
||||
* to keep a single request bounded — do not describe it as CrowdStrike's.
|
||||
*/
|
||||
const QUERY_LIMITS: Record<string, { min: number; max: number }> = {
|
||||
crowdstrike_query_sensors: { min: 1, max: 200 },
|
||||
crowdstrike_query_alerts: { min: 1, max: 10000 },
|
||||
@@ -356,10 +361,13 @@ export const CrowdStrikeBlock: BlockConfig<CrowdStrikeResponse> = {
|
||||
mode: 'advanced',
|
||||
},
|
||||
/**
|
||||
* Falcon has two sort spellings. Alerts, IOC Management, Spotlight, and Cases
|
||||
* document `field|direction`; Host Groups and Identity Protection sensors
|
||||
* document `field.direction`. One placeholder cannot show both, so the field
|
||||
* is declared twice under the same id with mutually exclusive conditions.
|
||||
* Falcon has two sort spellings. Alerts, Spotlight, and Cases document
|
||||
* `field|direction`; Host Groups, Identity Protection sensors, and IOC
|
||||
* Management document `field.direction`. IOC Management also has its own
|
||||
* field names — its sort enum has no `created_timestamp`, only `created_on`
|
||||
* and `modified_on` — so it gets a placeholder of its own. One placeholder
|
||||
* cannot show all three, so the field is declared three times under the same
|
||||
* id with mutually exclusive conditions.
|
||||
*/
|
||||
{
|
||||
id: 'sort',
|
||||
@@ -370,13 +378,20 @@ export const CrowdStrikeBlock: BlockConfig<CrowdStrikeResponse> = {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'crowdstrike_query_alerts',
|
||||
'crowdstrike_query_indicators',
|
||||
'crowdstrike_query_vulnerabilities',
|
||||
'crowdstrike_query_cases',
|
||||
],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'sort',
|
||||
title: 'Sort',
|
||||
type: 'short-input',
|
||||
placeholder: 'created_on.desc',
|
||||
condition: { field: 'operation', value: 'crowdstrike_query_indicators' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'sort',
|
||||
title: 'Sort',
|
||||
@@ -389,9 +404,18 @@ export const CrowdStrikeBlock: BlockConfig<CrowdStrikeResponse> = {
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
/**
|
||||
* CrowdStrike declares `include_hidden` with a default of `true` on all
|
||||
* three alert endpoints this switch feeds (`GET /alerts/queries/alerts/v2`,
|
||||
* `POST /alerts/entities/alerts/v2`, `PATCH /alerts/entities/alerts/v3`).
|
||||
* An untouched switch omits the parameter, so Falcon returns hidden alerts
|
||||
* either way — seeding `true` makes the rendered state match the wire
|
||||
* instead of showing off while hidden alerts come back.
|
||||
*/
|
||||
id: 'includeHidden',
|
||||
title: 'Include Hidden Alerts',
|
||||
type: 'switch',
|
||||
value: () => 'true',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
|
||||
@@ -103,6 +103,63 @@ describe('datadog list_monitors params', () => {
|
||||
expect(subBlock?.condition).toEqual({ field: 'operation', value: 'datadog_list_monitors' })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Both controls sit under Advanced with no help text, so Page Size reads as a
|
||||
* bound while Datadog ignores it unless a page is also sent.
|
||||
*/
|
||||
it('tells the user that a page size only applies with a page', () => {
|
||||
const pageSize = DatadogBlock.subBlocks.find((c) => c.id === 'listMonitorPageSize')
|
||||
const page = DatadogBlock.subBlocks.find((c) => c.id === 'listMonitorPage')
|
||||
|
||||
expect(pageSize?.tooltip).toMatch(/page number/i)
|
||||
expect(page?.tooltip).toMatch(/every monitor/i)
|
||||
})
|
||||
|
||||
it('states the same rule on the inputs the model reads', () => {
|
||||
expect(String(DatadogBlock.inputs.listMonitorPageSize.description)).toMatch(/page number/i)
|
||||
expect(String(DatadogBlock.inputs.listMonitorPage.description)).toMatch(/every monitor/i)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A bare `Number()` on a free-text field turns a typo or an unresolved reference
|
||||
* into `NaN`, which `JSON.stringify` writes as `null` and a query string carries
|
||||
* as the literal "NaN" — Datadog then rejects the call naming nothing the user
|
||||
* typed. Every numeric mapping goes through the shared coercion, not just the
|
||||
* two List Monitors fields.
|
||||
*/
|
||||
describe('datadog numeric coercion', () => {
|
||||
it.each([
|
||||
['datadog_mute_monitor', { muteMonitorId: '123', end: 'tomorrow' }, 'end'],
|
||||
['datadog_query_logs', { logLimit: 'lots' }, 'limit'],
|
||||
['datadog_query_timeseries', { from: 'yesterday', to: 'now' }, 'from'],
|
||||
['datadog_list_incidents', { incidentPageSize: '{{unresolved}}' }, 'pageSize'],
|
||||
['datadog_list_slos', { sloLimit: 'many' }, 'limit'],
|
||||
['datadog_list_dashboards', { dashboardCount: 'n/a' }, 'count'],
|
||||
['datadog_search_spans', { spanLimit: 'lots' }, 'limit'],
|
||||
['datadog_list_services', { servicePageSize: 'big' }, 'pageSize'],
|
||||
['datadog_list_security_rules', { rulePageNumber: 'first' }, 'pageNumber'],
|
||||
['datadog_list_synthetics_tests', { syntheticsPageSize: 'all' }, 'pageSize'],
|
||||
])('drops a non-numeric %s input rather than sending NaN', (operation, inputs, key) => {
|
||||
const params = mergedParams({ ...baseInputs, operation, ...inputs })
|
||||
|
||||
expect(params[key]).toBeUndefined()
|
||||
})
|
||||
|
||||
/**
|
||||
* An explicit 0 is a real offset/page/threshold. A `<Block.output>` reference
|
||||
* resolves to the number `0`, which the old truthiness guard dropped outright.
|
||||
*/
|
||||
it.each([
|
||||
['datadog_list_downtimes', { downtimeOffset: 0 }, 'offset'],
|
||||
['datadog_list_slos', { sloOffset: 0 }, 'offset'],
|
||||
['datadog_list_dashboards', { dashboardStart: 0 }, 'start'],
|
||||
])('keeps an explicit zero on %s', (operation, inputs, key) => {
|
||||
const params = mergedParams({ ...baseInputs, operation, ...inputs })
|
||||
|
||||
expect(params[key]).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('datadog create_monitor params', () => {
|
||||
|
||||
@@ -16,14 +16,17 @@ function toSwitchBoolean(value: unknown): boolean | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a List Monitors pagination input, dropping anything that is not a finite
|
||||
* number. These are advanced free-text fields, so they can carry a typo or an
|
||||
* unresolved reference, and a bare `Number()` would put the literal `NaN` in the
|
||||
* query string instead of omitting the parameter. An untouched subBlock resolves
|
||||
* to `null` and an empty one to `''`; both are omissions rather than zeros, while
|
||||
* an explicit `0` is Datadog's own first page and is kept.
|
||||
* Coerce a numeric block input, dropping anything that is not a finite number.
|
||||
*
|
||||
* These are free-text fields, so they can carry a typo or an unresolved
|
||||
* reference, and a bare `Number()` would put the literal `NaN` into the request
|
||||
* — `JSON.stringify` writes it as `null` and it reaches a query string as the
|
||||
* string "NaN", either of which Datadog rejects with a message naming nothing
|
||||
* the user typed. An untouched subBlock resolves to `null` and an empty one to
|
||||
* `''`; both are omissions rather than zeros, while an explicit `0` is
|
||||
* meaningful (page 0, offset 0, a zero threshold) and is kept.
|
||||
*/
|
||||
function datadogPageNumber(value: unknown): number | undefined {
|
||||
function datadogNumber(value: unknown): number | undefined {
|
||||
if (value == null || value === '') return undefined
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
@@ -533,6 +536,8 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
|
||||
title: 'Page Size',
|
||||
type: 'short-input',
|
||||
placeholder: '50',
|
||||
tooltip:
|
||||
'Monitors per page (max 1000). Datadog only applies a page size when a page number is sent, so setting this alone uses page 0.',
|
||||
condition: { field: 'operation', value: 'datadog_list_monitors' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
@@ -541,6 +546,8 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
|
||||
title: 'Page Number',
|
||||
type: 'short-input',
|
||||
placeholder: '0',
|
||||
tooltip:
|
||||
'Page to start from (0-indexed). Leaving both this and Page Size blank returns every monitor in the org without pagination.',
|
||||
condition: { field: 'operation', value: 'datadog_list_monitors' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
@@ -1100,9 +1107,20 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
|
||||
{ label: 'Monitor', id: 'monitor' },
|
||||
],
|
||||
value: () => 'metric',
|
||||
condition: { field: 'operation', value: ['datadog_create_slo', 'datadog_update_slo'] },
|
||||
condition: { field: 'operation', value: ['datadog_create_slo'] },
|
||||
required: { field: 'operation', value: ['datadog_create_slo'] },
|
||||
},
|
||||
{
|
||||
id: 'sloUpdateType',
|
||||
title: 'SLO Type',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Keep current', id: '' },
|
||||
{ label: 'Metric', id: 'metric' },
|
||||
{ label: 'Monitor', id: 'monitor' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['datadog_update_slo'] },
|
||||
},
|
||||
{
|
||||
id: 'sloThresholds',
|
||||
title: 'Thresholds (JSON)',
|
||||
@@ -1985,8 +2003,8 @@ Return ONLY the search query string - no explanations.`,
|
||||
return {
|
||||
...baseParams,
|
||||
query: params.query,
|
||||
from: params.from ? Number(params.from) : undefined,
|
||||
to: params.to ? Number(params.to) : undefined,
|
||||
from: datadogNumber(params.from),
|
||||
to: datadogNumber(params.to),
|
||||
}
|
||||
|
||||
case 'datadog_create_event':
|
||||
@@ -2007,7 +2025,7 @@ Return ONLY the search query string - no explanations.`,
|
||||
query: params.monitorQuery,
|
||||
message: params.message,
|
||||
tags: params.monitorTags,
|
||||
priority: params.monitorPriority ? Number(params.monitorPriority) : undefined,
|
||||
priority: datadogNumber(params.monitorPriority),
|
||||
options: params.options,
|
||||
}
|
||||
|
||||
@@ -2025,8 +2043,8 @@ Return ONLY the search query string - no explanations.`,
|
||||
* leftover value would filter this list while presenting it as complete.
|
||||
*/
|
||||
monitorTags: undefined,
|
||||
pageSize: datadogPageNumber(params.listMonitorPageSize),
|
||||
page: datadogPageNumber(params.listMonitorPage),
|
||||
pageSize: datadogNumber(params.listMonitorPageSize),
|
||||
page: datadogNumber(params.listMonitorPage),
|
||||
}
|
||||
|
||||
case 'datadog_mute_monitor':
|
||||
@@ -2034,7 +2052,7 @@ Return ONLY the search query string - no explanations.`,
|
||||
...baseParams,
|
||||
monitorId: params.muteMonitorId,
|
||||
scope: params.scope || undefined,
|
||||
end: params.end ? Number(params.end) : undefined,
|
||||
end: datadogNumber(params.end),
|
||||
}
|
||||
|
||||
case 'datadog_unmute_monitor':
|
||||
@@ -2051,7 +2069,7 @@ Return ONLY the search query string - no explanations.`,
|
||||
query: params.logQuery,
|
||||
from: params.logFrom,
|
||||
to: params.logTo,
|
||||
limit: params.logLimit ? Number(params.logLimit) : undefined,
|
||||
limit: datadogNumber(params.logLimit),
|
||||
cursor: params.logCursor || undefined,
|
||||
}
|
||||
|
||||
@@ -2063,8 +2081,8 @@ Return ONLY the search query string - no explanations.`,
|
||||
...baseParams,
|
||||
scope: params.downtimeScope,
|
||||
message: params.downtimeMessage,
|
||||
start: params.downtimeStart ? Number(params.downtimeStart) : undefined,
|
||||
end: params.downtimeEnd ? Number(params.downtimeEnd) : undefined,
|
||||
start: datadogNumber(params.downtimeStart),
|
||||
end: datadogNumber(params.downtimeEnd),
|
||||
monitorId: params.downtimeMonitorId,
|
||||
monitorTags: params.downtimeMonitorTags || undefined,
|
||||
timezone: params.downtimeTimezone || undefined,
|
||||
@@ -2075,8 +2093,8 @@ Return ONLY the search query string - no explanations.`,
|
||||
return {
|
||||
...baseParams,
|
||||
currentOnly: toSwitchBoolean(params.currentOnly),
|
||||
limit: params.downtimeLimit ? Number(params.downtimeLimit) : undefined,
|
||||
offset: params.downtimeOffset ? Number(params.downtimeOffset) : undefined,
|
||||
limit: datadogNumber(params.downtimeLimit),
|
||||
offset: datadogNumber(params.downtimeOffset),
|
||||
}
|
||||
|
||||
case 'datadog_cancel_downtime':
|
||||
@@ -2086,8 +2104,8 @@ Return ONLY the search query string - no explanations.`,
|
||||
return {
|
||||
...baseParams,
|
||||
include: params.incidentInclude || undefined,
|
||||
pageSize: params.incidentPageSize ? Number(params.incidentPageSize) : undefined,
|
||||
pageOffset: params.incidentPageOffset ? Number(params.incidentPageOffset) : undefined,
|
||||
pageSize: datadogNumber(params.incidentPageSize),
|
||||
pageOffset: datadogNumber(params.incidentPageOffset),
|
||||
}
|
||||
|
||||
case 'datadog_get_incident':
|
||||
@@ -2141,8 +2159,8 @@ Return ONLY the search query string - no explanations.`,
|
||||
query: params.sloQuery || undefined,
|
||||
tagsQuery: params.sloTagsQuery || undefined,
|
||||
metricsQuery: params.sloMetricsQuery || undefined,
|
||||
limit: params.sloLimit ? Number(params.sloLimit) : undefined,
|
||||
offset: params.sloOffset ? Number(params.sloOffset) : undefined,
|
||||
limit: datadogNumber(params.sloLimit),
|
||||
offset: datadogNumber(params.sloOffset),
|
||||
}
|
||||
|
||||
case 'datadog_get_slo':
|
||||
@@ -2163,12 +2181,8 @@ Return ONLY the search query string - no explanations.`,
|
||||
query: params.sloMetricQuery || undefined,
|
||||
monitorIds: params.sloMonitorIds || undefined,
|
||||
groups: params.sloGroups || undefined,
|
||||
targetThreshold: params.sloTargetThreshold
|
||||
? Number(params.sloTargetThreshold)
|
||||
: undefined,
|
||||
warningThreshold: params.sloWarningThreshold
|
||||
? Number(params.sloWarningThreshold)
|
||||
: undefined,
|
||||
targetThreshold: datadogNumber(params.sloTargetThreshold),
|
||||
warningThreshold: datadogNumber(params.sloWarningThreshold),
|
||||
timeframe: params.sloTimeframe || undefined,
|
||||
}
|
||||
|
||||
@@ -2177,19 +2191,15 @@ Return ONLY the search query string - no explanations.`,
|
||||
...baseParams,
|
||||
sloId: params.sloId,
|
||||
name: params.sloName || undefined,
|
||||
type: params.sloType || undefined,
|
||||
type: params.sloUpdateType || undefined,
|
||||
thresholds: params.sloThresholds || undefined,
|
||||
description: params.sloDescription || undefined,
|
||||
tags: params.sloTags || undefined,
|
||||
query: params.sloMetricQuery || undefined,
|
||||
monitorIds: params.sloMonitorIds || undefined,
|
||||
groups: params.sloGroups || undefined,
|
||||
targetThreshold: params.sloTargetThreshold
|
||||
? Number(params.sloTargetThreshold)
|
||||
: undefined,
|
||||
warningThreshold: params.sloWarningThreshold
|
||||
? Number(params.sloWarningThreshold)
|
||||
: undefined,
|
||||
targetThreshold: datadogNumber(params.sloTargetThreshold),
|
||||
warningThreshold: datadogNumber(params.sloWarningThreshold),
|
||||
timeframe: params.sloTimeframe || undefined,
|
||||
}
|
||||
|
||||
@@ -2204,9 +2214,9 @@ Return ONLY the search query string - no explanations.`,
|
||||
return {
|
||||
...baseParams,
|
||||
sloId: params.sloId,
|
||||
fromTs: params.sloFromTs ? Number(params.sloFromTs) : undefined,
|
||||
toTs: params.sloToTs ? Number(params.sloToTs) : undefined,
|
||||
target: params.sloTarget ? Number(params.sloTarget) : undefined,
|
||||
fromTs: datadogNumber(params.sloFromTs),
|
||||
toTs: datadogNumber(params.sloToTs),
|
||||
target: datadogNumber(params.sloTarget),
|
||||
applyCorrection: toSwitchBoolean(params.sloApplyCorrection),
|
||||
}
|
||||
|
||||
@@ -2215,8 +2225,8 @@ Return ONLY the search query string - no explanations.`,
|
||||
...baseParams,
|
||||
filterShared: toSwitchBoolean(params.dashboardFilterShared),
|
||||
filterDeleted: toSwitchBoolean(params.dashboardFilterDeleted),
|
||||
count: params.dashboardCount ? Number(params.dashboardCount) : undefined,
|
||||
start: params.dashboardStart ? Number(params.dashboardStart) : undefined,
|
||||
count: datadogNumber(params.dashboardCount),
|
||||
start: datadogNumber(params.dashboardStart),
|
||||
}
|
||||
|
||||
case 'datadog_get_dashboard':
|
||||
@@ -2241,10 +2251,8 @@ Return ONLY the search query string - no explanations.`,
|
||||
case 'datadog_list_synthetics_tests':
|
||||
return {
|
||||
...baseParams,
|
||||
pageSize: params.syntheticsPageSize ? Number(params.syntheticsPageSize) : undefined,
|
||||
pageNumber: params.syntheticsPageNumber
|
||||
? Number(params.syntheticsPageNumber)
|
||||
: undefined,
|
||||
pageSize: datadogNumber(params.syntheticsPageSize),
|
||||
pageNumber: datadogNumber(params.syntheticsPageNumber),
|
||||
}
|
||||
|
||||
case 'datadog_get_synthetics_test':
|
||||
@@ -2255,8 +2263,8 @@ Return ONLY the search query string - no explanations.`,
|
||||
return {
|
||||
...baseParams,
|
||||
publicId: params.syntheticsPublicId,
|
||||
fromTs: params.syntheticsFromTs ? Number(params.syntheticsFromTs) : undefined,
|
||||
toTs: params.syntheticsToTs ? Number(params.syntheticsToTs) : undefined,
|
||||
fromTs: datadogNumber(params.syntheticsFromTs),
|
||||
toTs: datadogNumber(params.syntheticsToTs),
|
||||
probeDc: params.syntheticsProbeDc || undefined,
|
||||
}
|
||||
|
||||
@@ -2278,7 +2286,7 @@ Return ONLY the search query string - no explanations.`,
|
||||
to: params.signalTo || undefined,
|
||||
sort: params.signalSort || undefined,
|
||||
cursor: params.signalCursor || undefined,
|
||||
limit: params.signalLimit ? Number(params.signalLimit) : undefined,
|
||||
limit: datadogNumber(params.signalLimit),
|
||||
}
|
||||
|
||||
case 'datadog_get_security_signal':
|
||||
@@ -2305,8 +2313,8 @@ Return ONLY the search query string - no explanations.`,
|
||||
...baseParams,
|
||||
query: params.ruleQuery || undefined,
|
||||
sort: params.ruleSort || undefined,
|
||||
pageSize: params.rulePageSize ? Number(params.rulePageSize) : undefined,
|
||||
pageNumber: params.rulePageNumber ? Number(params.rulePageNumber) : undefined,
|
||||
pageSize: datadogNumber(params.rulePageSize),
|
||||
pageNumber: datadogNumber(params.rulePageNumber),
|
||||
}
|
||||
|
||||
case 'datadog_search_spans':
|
||||
@@ -2317,14 +2325,14 @@ Return ONLY the search query string - no explanations.`,
|
||||
to: params.spanTo || undefined,
|
||||
sort: params.spanSort || undefined,
|
||||
cursor: params.spanCursor || undefined,
|
||||
limit: params.spanLimit ? Number(params.spanLimit) : undefined,
|
||||
limit: datadogNumber(params.spanLimit),
|
||||
}
|
||||
|
||||
case 'datadog_list_services':
|
||||
return {
|
||||
...baseParams,
|
||||
pageSize: params.servicePageSize ? Number(params.servicePageSize) : undefined,
|
||||
pageNumber: params.servicePageNumber ? Number(params.servicePageNumber) : undefined,
|
||||
pageSize: datadogNumber(params.servicePageSize),
|
||||
pageNumber: datadogNumber(params.servicePageNumber),
|
||||
schemaVersion: params.serviceSchemaVersion || undefined,
|
||||
}
|
||||
|
||||
@@ -2390,8 +2398,16 @@ Return ONLY the search query string - no explanations.`,
|
||||
downtimeId: { type: 'string', description: 'Downtime ID to cancel' },
|
||||
listMonitorName: { type: 'string', description: 'Filter monitors by name' },
|
||||
listMonitorTags: { type: 'string', description: 'Filter monitors by tags' },
|
||||
listMonitorPageSize: { type: 'number', description: 'Monitors to return per page' },
|
||||
listMonitorPage: { type: 'number', description: 'Monitor page number (0-indexed)' },
|
||||
listMonitorPageSize: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Monitors to return per page (max 1000). Datadog only applies this when a page number is sent, so setting it alone uses page 0.',
|
||||
},
|
||||
listMonitorPage: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Monitor page number (0-indexed). With neither this nor the page size set, Datadog returns every monitor in the org without pagination.',
|
||||
},
|
||||
// Incidents
|
||||
incidentId: { type: 'string', description: 'Incident UUID' },
|
||||
incidentTitle: { type: 'string', description: 'Incident title' },
|
||||
@@ -2415,6 +2431,10 @@ Return ONLY the search query string - no explanations.`,
|
||||
sloId: { type: 'string', description: 'SLO ID' },
|
||||
sloName: { type: 'string', description: 'SLO name' },
|
||||
sloType: { type: 'string', description: 'SLO type' },
|
||||
sloUpdateType: {
|
||||
type: 'string',
|
||||
description: 'Replacement SLO type, or blank to keep current',
|
||||
},
|
||||
sloThresholds: { type: 'json', description: 'SLO thresholds' },
|
||||
sloDescription: { type: 'string', description: 'SLO description' },
|
||||
sloTags: { type: 'string', description: 'SLO tags' },
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SCOPE_DESCRIPTIONS } from '@/lib/oauth/utils'
|
||||
import { MicrosoftAdBlock } from '@/blocks/blocks/microsoft_ad'
|
||||
import * as microsoftAdTools from '@/tools/microsoft_ad'
|
||||
|
||||
/**
|
||||
* The tri-state assertions run against `{ ...inputs, ...buildParams(inputs) }`, the shape the
|
||||
@@ -121,9 +123,22 @@ describe('MicrosoftAdBlock', () => {
|
||||
/**
|
||||
* `User.ReadWrite.All` is listed on every `/users` read this block performs — list, get,
|
||||
* licenseDetails, registeredDevices, and ownedDevices — so `User.Read.All` was pure consent
|
||||
* noise. `Directory.Read.All` and `GroupMember.ReadWrite.All` are deliberately retained:
|
||||
* `GET /subscribedSkus` names neither `LicenseAssignment.ReadWrite.All` nor any scope left in
|
||||
* this list, and `POST /groups/{id}/members/$ref` accepts `GroupMember.ReadWrite.All` only.
|
||||
* noise.
|
||||
*
|
||||
* `Directory.Read.All` was required by exactly one call, `GET /subscribedSkus`, whose
|
||||
* permission table names `LicenseAssignment.Read.All` as least privileged and
|
||||
* `Directory.Read.All` only as a higher-privileged alternative — and does **not** list
|
||||
* `LicenseAssignment.ReadWrite.All`, so the write scope this block already holds does not
|
||||
* cover the read. Every other call is covered by a narrower scope in the list:
|
||||
* directory roles by `RoleManagement.ReadWrite.Directory`, group members by
|
||||
* `Group.ReadWrite.All`/`GroupMember.ReadWrite.All`, devices by `Device.Read.All`, audits by
|
||||
* `AuditLog.Read.All`, service principals by `Application.Read.All`, user app-role
|
||||
* assignments by `AppRoleAssignment.ReadWrite.All`, and CA policies by `Policy.Read.All`.
|
||||
*
|
||||
* `GroupMember.ReadWrite.All` is deliberately retained: `POST /groups/{id}/members/$ref`
|
||||
* accepts it and nothing else in this list for a user member.
|
||||
* @see https://learn.microsoft.com/en-us/graph/api/subscribedsku-list
|
||||
* @see https://learn.microsoft.com/en-us/graph/api/group-post-members
|
||||
*/
|
||||
describe('requested OAuth scopes', () => {
|
||||
const requiredScopes =
|
||||
@@ -135,10 +150,39 @@ describe('MicrosoftAdBlock', () => {
|
||||
expect(requiredScopes).not.toContain('User.Read.All')
|
||||
})
|
||||
|
||||
it('requests the least-privileged scope for subscribedSkus, not Directory.Read.All', () => {
|
||||
expect(requiredScopes).toContain('LicenseAssignment.Read.All')
|
||||
expect(requiredScopes).not.toContain('Directory.Read.All')
|
||||
})
|
||||
|
||||
it('keeps the scopes no retained scope covers', () => {
|
||||
expect(requiredScopes).toEqual(
|
||||
expect.arrayContaining(['Directory.Read.All', 'GroupMember.ReadWrite.All'])
|
||||
)
|
||||
expect(requiredScopes).toEqual(expect.arrayContaining(['GroupMember.ReadWrite.All']))
|
||||
})
|
||||
|
||||
it('describes every scope it requests', () => {
|
||||
const undescribed = requiredScopes.filter((scope) => !SCOPE_DESCRIPTIONS[scope])
|
||||
expect(undescribed).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* `getBlockOutputs` derives the referenceable schema from `blockConfig.outputs`, so a single
|
||||
* `response` entry made the tag dropdown offer `<microsoft_ad.response>` — which corresponds to
|
||||
* nothing, since every tool puts its fields at the top level of `output` — and left the real
|
||||
* outputs unreferenceable downstream.
|
||||
*/
|
||||
describe('declared outputs', () => {
|
||||
const toolOutputKeys = new Set(
|
||||
Object.values(microsoftAdTools).flatMap((tool) => Object.keys(tool.outputs ?? {}))
|
||||
)
|
||||
const blockOutputKeys = new Set(Object.keys(MicrosoftAdBlock.outputs))
|
||||
|
||||
it('declares every key its tools emit', () => {
|
||||
expect([...toolOutputKeys].filter((key) => !blockOutputKeys.has(key)).sort()).toEqual([])
|
||||
})
|
||||
|
||||
it('declares nothing no tool emits', () => {
|
||||
expect([...blockOutputKeys].filter((key) => !toolOutputKeys.has(key)).sort()).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -919,10 +919,129 @@ export const MicrosoftAdBlock: BlockConfig<MicrosoftAdResponse> = {
|
||||
policyFilter: { type: 'string' },
|
||||
},
|
||||
outputs: {
|
||||
response: {
|
||||
type: 'json',
|
||||
users: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Microsoft Entra ID operation response. User operations return id, displayName, userPrincipalName, mail, jobTitle, department. Group operations return id, displayName, description, mailEnabled, securityEnabled, groupTypes. Member operations return id, displayName, mail, odataType. Licensing operations return skuId, skuPartNumber, consumedUnits, prepaidUnits, and servicePlans. Sign-in and audit operations return the event id, timestamp, actor, target, and result. App role and directory role operations return assignment and role ids with their principals. Device operations return id, deviceId, displayName, operatingSystem, accountEnabled, isCompliant, isManaged, and trustType. Conditional access operations return id, displayName, state, conditions, grantControls, and sessionControls. List operations also return nextLink for fetching additional pages.',
|
||||
'User objects (id, displayName, givenName, surname, userPrincipalName, mail, jobTitle, department, officeLocation, mobilePhone, accountEnabled)',
|
||||
},
|
||||
userCount: { type: 'number', description: 'Number of users returned on this page' },
|
||||
user: { type: 'json', description: 'The single user this operation created or read' },
|
||||
userId: { type: 'string', description: 'Object ID of the user the operation acted on' },
|
||||
userPrincipalName: { type: 'string', description: 'User principal name of that user' },
|
||||
displayName: { type: 'string', description: 'Display name of the user the operation acted on' },
|
||||
groups: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Group objects (id, displayName, description, mail, mailEnabled, securityEnabled, groupTypes, visibility)',
|
||||
},
|
||||
groupCount: { type: 'number', description: 'Number of groups returned on this page' },
|
||||
group: { type: 'json', description: 'The single group this operation created or read' },
|
||||
groupId: { type: 'string', description: 'Object ID of the group the operation acted on' },
|
||||
members: {
|
||||
type: 'array',
|
||||
description: 'Group or directory role members (id, displayName, mail, odataType)',
|
||||
},
|
||||
memberCount: { type: 'number', description: 'Number of members returned on this page' },
|
||||
memberId: { type: 'string', description: 'Object ID of the member the operation acted on' },
|
||||
roles: {
|
||||
type: 'array',
|
||||
description: 'Activated directory roles (id, displayName, description, roleTemplateId)',
|
||||
},
|
||||
roleCount: { type: 'number', description: 'Number of directory roles returned' },
|
||||
directoryRoleId: {
|
||||
type: 'string',
|
||||
description: 'Object ID of the directory role the operation acted on',
|
||||
},
|
||||
skus: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Subscribed SKUs (skuId, skuPartNumber, consumedUnits, prepaidUnits, servicePlans)',
|
||||
},
|
||||
skuCount: { type: 'number', description: 'Number of subscribed SKUs returned' },
|
||||
licenses: {
|
||||
type: 'array',
|
||||
description: 'License details assigned to the user (skuId, skuPartNumber, servicePlans)',
|
||||
},
|
||||
licenseCount: { type: 'number', description: 'Number of license details returned' },
|
||||
assignedLicenses: {
|
||||
type: 'array',
|
||||
description: 'Licenses the user holds after the assign or remove operation',
|
||||
},
|
||||
assignments: {
|
||||
type: 'array',
|
||||
description:
|
||||
'App role assignments (id, appRoleId, principalId, principalDisplayName, resourceId, resourceDisplayName)',
|
||||
},
|
||||
assignmentCount: { type: 'number', description: 'Number of app role assignments returned' },
|
||||
assignment: { type: 'json', description: 'The app role assignment this operation created' },
|
||||
appRoleAssignmentId: {
|
||||
type: 'string',
|
||||
description: 'ID of the app role assignment that was removed',
|
||||
},
|
||||
servicePrincipals: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Service principals (id, appId, displayName, servicePrincipalType, accountEnabled, appRoles)',
|
||||
},
|
||||
servicePrincipalCount: { type: 'number', description: 'Number of service principals returned' },
|
||||
devices: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Devices (id, deviceId, displayName, operatingSystem, accountEnabled, isCompliant, isManaged, trustType)',
|
||||
},
|
||||
deviceCount: { type: 'number', description: 'Number of devices returned on this page' },
|
||||
device: { type: 'json', description: 'The single device this operation read' },
|
||||
policies: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Conditional access policies (id, displayName, state, conditions, grantControls, sessionControls)',
|
||||
},
|
||||
policyCount: { type: 'number', description: 'Number of conditional access policies returned' },
|
||||
policy: {
|
||||
type: 'json',
|
||||
description: 'The single conditional access policy this operation read',
|
||||
},
|
||||
audits: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Directory audit events (id, activityDateTime, activityDisplayName, initiatedBy, targetResources, result)',
|
||||
},
|
||||
auditCount: { type: 'number', description: 'Number of directory audit events returned' },
|
||||
signIns: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Sign-in events (id, createdDateTime, userPrincipalName, appDisplayName, ipAddress, status)',
|
||||
},
|
||||
signInCount: { type: 'number', description: 'Number of sign-in events returned' },
|
||||
methods: {
|
||||
type: 'array',
|
||||
description: 'Registered authentication methods for the user (id, odataType, and its detail)',
|
||||
},
|
||||
methodCount: { type: 'number', description: 'Number of authentication methods returned' },
|
||||
newPassword: {
|
||||
type: 'string',
|
||||
description: 'Temporary password produced by the password reset, when Graph returned one',
|
||||
},
|
||||
operationLocation: {
|
||||
type: 'string',
|
||||
description: 'URL for polling the long-running password reset operation',
|
||||
},
|
||||
accepted: {
|
||||
type: 'boolean',
|
||||
description: 'True when Graph accepted the password reset for asynchronous processing',
|
||||
},
|
||||
forceChangePasswordNextSignIn: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the user must change the password at next sign-in',
|
||||
},
|
||||
added: { type: 'boolean', description: 'True when the member was added' },
|
||||
removed: { type: 'boolean', description: 'True when the member or assignment was removed' },
|
||||
updated: { type: 'boolean', description: 'True when the resource was updated' },
|
||||
deleted: { type: 'boolean', description: 'True when the resource was deleted' },
|
||||
revoked: { type: 'boolean', description: "True when the user's sign-in sessions were revoked" },
|
||||
nextLink: {
|
||||
type: 'string',
|
||||
description: 'Continuation URL for the next page, present only when more results exist',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MSSQLBlock } from '@/blocks/blocks/mssql'
|
||||
import * as mssqlTools from '@/tools/mssql'
|
||||
|
||||
/**
|
||||
* Every assertion here runs against `{ ...inputs, ...buildParams(inputs) }`, the
|
||||
@@ -137,3 +138,63 @@ describe('MSSQLBlock', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Microsoft SQL Server tool declarations', () => {
|
||||
it('never lets an LLM choose which database to open', () => {
|
||||
// Every other connection field, on every other tool, is user-only. A model
|
||||
// picking the database means the user's credentials open something else.
|
||||
for (const tool of Object.values(mssqlTools)) {
|
||||
for (const field of ['host', 'port', 'database', 'username', 'password']) {
|
||||
const param = tool.params[field]
|
||||
if (!param) continue
|
||||
expect(param.visibility, `${tool.id}.${field}`).toBe('user-only')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('declares the introspection table shape so downstream blocks get field hints', () => {
|
||||
const tables = mssqlTools.mssqlIntrospectTool.outputs?.tables as {
|
||||
items?: { type: string; properties?: Record<string, unknown> }
|
||||
}
|
||||
|
||||
expect(tables.items?.type).toBe('object')
|
||||
expect(Object.keys(tables.items?.properties ?? {})).toEqual([
|
||||
'name',
|
||||
'schema',
|
||||
'columns',
|
||||
'primaryKey',
|
||||
'foreignKeys',
|
||||
'indexes',
|
||||
])
|
||||
expect(
|
||||
(tables.items?.properties?.columns as { items?: { properties?: Record<string, unknown> } })
|
||||
?.items?.properties
|
||||
).toHaveProperty('references')
|
||||
})
|
||||
|
||||
/**
|
||||
* `getBlockOutputs` derives the referenceable schema from `blockConfig.outputs`, so a key a
|
||||
* tool emits but the block omits is unreferenceable downstream — and for the truncation pair
|
||||
* that also leaves the block's advertised schema describing every result as complete.
|
||||
*/
|
||||
it('has a block that declares every output key its tools emit', () => {
|
||||
const toolKeys = new Set(
|
||||
Object.values(mssqlTools).flatMap((tool) => Object.keys(tool.outputs ?? {}))
|
||||
)
|
||||
const blockKeys = new Set(Object.keys(MSSQLBlock.outputs))
|
||||
|
||||
expect([...toolKeys].filter((key) => !blockKeys.has(key)).sort()).toEqual([])
|
||||
expect(MSSQLBlock.outputs.truncated).toMatchObject({ type: 'boolean' })
|
||||
expect(MSSQLBlock.outputs.truncationReason).toMatchObject({ type: 'string' })
|
||||
})
|
||||
|
||||
it('does not present TLS encryption as guaranteed once enabled', () => {
|
||||
// TDS 7.4 starts in-band TLS only if the prelogin response is ON/REQ; a
|
||||
// server answering NOT_SUP yields an unencrypted session with no error.
|
||||
for (const tool of Object.values(mssqlTools)) {
|
||||
const encrypt = tool.params.encrypt
|
||||
if (!encrypt) continue
|
||||
expect(encrypt.description, tool.id).toMatch(/negotiat|not a guarantee/i)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -397,6 +397,14 @@ export const MSSQLBlock: BlockConfig<MSSQLResponse> = {
|
||||
type: 'array',
|
||||
description: 'List of available schemas in the database (introspect operation)',
|
||||
},
|
||||
truncated: {
|
||||
type: 'boolean',
|
||||
description: 'True when the result hit a row or byte ceiling and rows were dropped',
|
||||
},
|
||||
truncationReason: {
|
||||
type: 'string',
|
||||
description: 'Explanation of the ceiling that truncated the result',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { normalizeFileInput } from '@/blocks/utils'
|
||||
import {
|
||||
APPROVAL_DECISION_OPTIONS,
|
||||
APPROVAL_STATE,
|
||||
APPROVAL_STATE_OPTIONS,
|
||||
CHANGE_CLOSE_CODE_OPTIONS,
|
||||
CHANGE_STATE_OPTIONS,
|
||||
CHANGE_TYPE_OPTIONS,
|
||||
@@ -513,6 +514,8 @@ Output: {"short_description": "Network outage", "description": "Network connecti
|
||||
type: 'short-input',
|
||||
placeholder: '10',
|
||||
condition: { field: 'operation', value: ['servicenow_read_record', ...PAGINATED_OPS] },
|
||||
description:
|
||||
'Maximum number of records to return. Left blank, no limit is sent and the instance applies its own default, which is 10,000 on the Table API operations',
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
@@ -890,16 +893,11 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
|
||||
id: 'approvalState',
|
||||
title: 'Approval State',
|
||||
type: 'combobox',
|
||||
options: [
|
||||
{ label: 'Any (not set)', id: '' },
|
||||
{ label: 'Requested (pending)', id: APPROVAL_STATE.REQUESTED },
|
||||
{ label: 'Approved', id: APPROVAL_STATE.APPROVED },
|
||||
{ label: 'Rejected', id: APPROVAL_STATE.REJECTED },
|
||||
],
|
||||
options: [{ label: 'Any (not set)', id: '' }, ...APPROVAL_STATE_OPTIONS],
|
||||
value: () => APPROVAL_STATE.REQUESTED,
|
||||
condition: { field: 'operation', value: 'servicenow_list_approvals' },
|
||||
description:
|
||||
'ServiceNow publishes coded values only for these three. An instance that defines further approval states can be filtered by typing the raw value.',
|
||||
'The seven states ServiceNow publishes for the Ask for Approval action. An instance that defines further approval states can be filtered by typing the raw value.',
|
||||
},
|
||||
// Prioritization
|
||||
{
|
||||
@@ -1677,10 +1675,29 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
|
||||
rest.closeNotes = CHANGE_CLOSE_OPS.has(operation) ? changeCloseNotes : resolutionNotes
|
||||
if (operation === 'servicenow_search_knowledge') rest.query = knowledgeQuery
|
||||
|
||||
if (attachmentLimit != null && attachmentLimit !== '') rest.limit = Number(attachmentLimit)
|
||||
if (rest.limit != null && rest.limit !== '') rest.limit = Number(rest.limit)
|
||||
if (rest.offset != null && rest.offset !== '') rest.offset = Number(rest.offset)
|
||||
if (rest.quantity != null && rest.quantity !== '') rest.quantity = Number(rest.quantity)
|
||||
/**
|
||||
* `attachmentLimit` exists as a separate subblock precisely so a limit
|
||||
* typed on one operation cannot leak into another, but neither branch
|
||||
* used to be scoped, so whichever ran last won: List Incidents took a
|
||||
* stale `attachmentLimit`, and List Attachments took a stale `limit`.
|
||||
* Every paginated operation reads exactly one of the two.
|
||||
*/
|
||||
if (operation === 'servicenow_list_attachments') {
|
||||
rest.limit =
|
||||
attachmentLimit != null && attachmentLimit !== '' ? Number(attachmentLimit) : undefined
|
||||
} else if (rest.limit != null) {
|
||||
rest.limit = rest.limit === '' ? undefined : Number(rest.limit)
|
||||
}
|
||||
/**
|
||||
* A short-input stores `''` once a user types a value and clears it
|
||||
* again, so a blank must resolve to `undefined` rather than stay in
|
||||
* place — the tools only skip a param that is absent, and a retained
|
||||
* `''` reaches ServiceNow as `sysparm_limit=`.
|
||||
*/
|
||||
if (rest.offset != null) rest.offset = rest.offset === '' ? undefined : Number(rest.offset)
|
||||
if (rest.quantity != null) {
|
||||
rest.quantity = rest.quantity === '' ? undefined : Number(rest.quantity)
|
||||
}
|
||||
|
||||
if (rest.inputDisplayValue != null) {
|
||||
rest.inputDisplayValue =
|
||||
@@ -1981,24 +1998,55 @@ export const ServiceNowBlockMeta = {
|
||||
skills: [
|
||||
{
|
||||
name: 'create-incident',
|
||||
description:
|
||||
'Create a new ServiceNow incident record with the right category, priority, and description.',
|
||||
description: 'File a ServiceNow incident with the right category, priority, and description.',
|
||||
content:
|
||||
'# Create Incident\n\nFile a new ServiceNow incident from a reported issue.\n\n## Steps\n1. Use the Create Record operation against the incident table.\n2. Populate the field values: a clear short description, the longer description, category, and priority or impact and urgency.\n3. Set caller or assignment group fields when known.\n\n## Output\nReturn the created record sys_id and incident number so the reporter can track it, and echo the category and priority that were set.',
|
||||
'# Create Incident\n\nFile a new ServiceNow incident from a reported issue.\n\n## Steps\n1. Use the Create Incident operation. It targets the incident table for you — do not reach for the generic Create Record operation, which makes you name the table and hand-build the field payload.\n2. Set a clear short description, the longer description, and the category.\n3. Set priority through impact and urgency rather than writing priority directly; ServiceNow derives priority from those two and overwrites a directly written value.\n4. Set the caller and assignment group when they are known.\n\n## Output\nReturn the created sys_id and incident number so the reporter can track it, and echo the category and the derived priority.',
|
||||
},
|
||||
{
|
||||
name: 'search-records',
|
||||
name: 'triage-incidents',
|
||||
description:
|
||||
'Query a ServiceNow table for records matching a condition and return the matching rows.',
|
||||
'Find the ServiceNow incidents matching a state, priority, or assignment and summarize them.',
|
||||
content:
|
||||
'# Search Records\n\nFind records in any ServiceNow table that match a condition.\n\n## Steps\n1. Use the Read Records operation against the target table (for example incident, change_request, or sc_task).\n2. Provide an encoded query to filter (for example active incidents in a category) and limit the number of rows returned.\n3. Choose the display-value setting so returned fields are human-readable rather than raw sys_ids when needed.\n\n## Output\nReturn the matched records with their key fields and sys_ids, and report how many matched the query.',
|
||||
'# Triage Incidents\n\nPull the incidents that need attention and summarize them.\n\n## Steps\n1. Use the List Incidents operation with the filters it exposes — state, priority, assignment group, or an encoded query for anything else. Reserve the generic Read Records operation for tables that have no semantic operation of their own.\n2. Always set the limit. Nothing sends one for you, and the ServiceNow Table API falls back to its own default of 10,000 records, so an unset limit pulls far more than you will read.\n3. Keep the display-value setting at "all" so reference fields come back with both their sys_id and a human-readable label.\n4. Read a single incident in full with Get Incident once you have picked one out.\n\n## Output\nSummarize the matched incidents by number, short description, state, and priority, and say how many matched.',
|
||||
},
|
||||
{
|
||||
name: 'update-record-status',
|
||||
name: 'progress-incident',
|
||||
description:
|
||||
'Update fields on an existing ServiceNow record, such as state, assignment, or work notes.',
|
||||
'Move a ServiceNow incident forward — comment on it, update fields, resolve it, or close it.',
|
||||
content:
|
||||
'# Update Record Status\n\nModify an existing ServiceNow record once a decision or action is taken.\n\n## Steps\n1. Identify the record by its sys_id (from a search step or a notification).\n2. Use the Update Record operation against the correct table, supplying only the fields to change such as state, assigned_to, or work_notes.\n3. Confirm the change by reading the record back.\n\n## Output\nConfirm the record number, the fields that changed, and their new values so the update is auditable.',
|
||||
'# Progress an Incident\n\nAdvance an incident once work has happened on it.\n\n## Steps\n1. Identify the incident by number or sys_id, from List Incidents or from the trigger payload.\n2. Pick the operation that matches the action: Add Incident Comment for a note, Update Incident for field changes, Resolve Incident when a fix is in place, Close Incident to finish it.\n3. Resolve Incident requires a close code and close notes — ServiceNow rejects a resolution without them.\n4. When commenting, choose the journal field deliberately: work notes are internal, additional comments are visible to the caller.\n\n## Output\nConfirm the incident number, which operation ran, and the resulting state so the update is auditable.',
|
||||
},
|
||||
{
|
||||
name: 'manage-change-request',
|
||||
description:
|
||||
'Raise a ServiceNow change request and drive it through its approval state machine.',
|
||||
content:
|
||||
'# Manage a Change Request\n\nRaise a change and move it through its lifecycle.\n\n## Steps\n1. Use Create Change Request, choosing the type — normal, standard, or emergency — that matches the risk. Standard changes are pre-approved; emergency changes skip the usual review.\n2. Use Update Change Request for field edits such as the plans, schedule, or assignment group.\n3. Do not write the state field directly. Call Get Change Next States first to learn which transitions the state machine currently permits, then Move Change State to take one — the machine rejects a transition whose conditions have not been met, and the error will not say which condition failed.\n4. Use List Change Tasks to see the work items under the change.\n\n## Output\nReport the change number, its current state, and the transitions that are still available from here.',
|
||||
},
|
||||
{
|
||||
name: 'order-catalog-item',
|
||||
description: 'Order a ServiceNow catalog item and track the requested item it produces.',
|
||||
content:
|
||||
'# Order a Catalog Item\n\nSubmit a service catalog request on behalf of a requester.\n\n## Steps\n1. Use List Catalog Items to find the item and its sys_id; ordering needs the sys_id, not the display name.\n2. Use Order Catalog Item with that sys_id, the quantity, and the item variables. Variables are item-specific — read them off the catalog item rather than assuming a shape.\n3. Track the result with List Requested Items or Get Requested Item, which is where the fulfillment state lives.\n\n## Output\nReturn the request number, the requested-item number, and the current fulfillment state.',
|
||||
},
|
||||
{
|
||||
name: 'handle-approvals',
|
||||
description: 'Find pending ServiceNow approvals and record an approve or reject decision.',
|
||||
content:
|
||||
'# Handle Approvals\n\nWork the approval queue.\n\n## Steps\n1. Use List Approvals filtered by state. The pending state is `requested`; the state values are not uniformly punctuated — `not requested` has a space while `not_required` has an underscore — so pick from the offered list instead of typing one.\n2. Read what is being approved before deciding. The approval record points at a source record; fetch it with Get Change Request or Get Requested Item.\n3. Use Approve or Reject to record the decision. Only those two are writable here — every other approval state is set by the approval engine, not by an approver.\n4. Include a comment explaining the decision when the process expects a justification.\n\n## Output\nConfirm which approval record was acted on, the decision recorded, and what it unblocks.',
|
||||
},
|
||||
{
|
||||
name: 'investigate-cmdb',
|
||||
description:
|
||||
'Look up a ServiceNow configuration item and map what it depends on and what depends on it.',
|
||||
content:
|
||||
'# Investigate the CMDB\n\nEstablish blast radius before a change or during an incident.\n\n## Steps\n1. Use Search Configuration Items to locate the CI by name or class, then Get Configuration Item for its full record.\n2. Use List CI Relationships to walk the dependency graph. Choose the direction deliberately: parents are what this CI depends on, children are what depends on it, and impact analysis usually wants the children.\n3. Follow the graph outward one hop at a time rather than pulling everything at once — CMDB relationship sets grow quickly.\n\n## Output\nName the CI, its class and operational status, and list the dependent CIs a change to it would affect.',
|
||||
},
|
||||
{
|
||||
name: 'search-knowledge',
|
||||
description: 'Search the ServiceNow knowledge base for an existing fix before escalating.',
|
||||
content:
|
||||
'# Search Knowledge\n\nCheck whether a documented fix already exists.\n\n## Steps\n1. Use Search Knowledge with the wording the reporter used; the search runs over article text, so their own phrasing usually matches better than a normalized restatement.\n2. Use Get Knowledge Article to pull the full body of a promising hit — the search result carries only a snippet.\n3. Prefer this before Create Incident when the issue looks routine; a linked article often resolves it outright.\n\n## Output\nCite the article number and title, summarize the fix, and say plainly if nothing relevant was found so the caller escalates instead of guessing.',
|
||||
},
|
||||
],
|
||||
} as const satisfies BlockMeta
|
||||
|
||||
@@ -7,7 +7,8 @@ vi.mock('@/triggers', () => ({
|
||||
getTrigger: () => ({ subBlocks: [] }),
|
||||
}))
|
||||
|
||||
import { SplunkBlock } from '@/blocks/blocks/splunk'
|
||||
import { SplunkBlock, SplunkBlockMeta } from '@/blocks/blocks/splunk'
|
||||
import { buildSplunkFormBody, buildSplunkUrl } from '@/tools/splunk/utils'
|
||||
|
||||
const toParams = SplunkBlock.tools.config?.params
|
||||
|
||||
@@ -63,10 +64,10 @@ describe('SplunkBlock tools.config.params', () => {
|
||||
|
||||
describe('pagination', () => {
|
||||
it('omits Max Results when untouched rather than asking for every row', () => {
|
||||
const result = mapParams({ operation: 'splunk_list_indexes', count: null, offset: null })
|
||||
const merged = mergedInputs({ operation: 'splunk_list_indexes', count: null, offset: null })
|
||||
|
||||
expect(result).not.toHaveProperty('count')
|
||||
expect(result).not.toHaveProperty('offset')
|
||||
expect(merged.count ?? undefined).toBeUndefined()
|
||||
expect(merged.offset ?? undefined).toBeUndefined()
|
||||
})
|
||||
|
||||
it('coerces a typed Max Results, including an explicit 0', () => {
|
||||
@@ -193,28 +194,66 @@ describe('SplunkBlock numeric coercion', () => {
|
||||
* A bare `Number()` sent `NaN` for an unparseable value, which serializes as the
|
||||
* literal `NaN` and makes Splunk reject the request with an error that names the
|
||||
* field but not the cause. Omitting it lets Splunk apply its own default.
|
||||
*
|
||||
* "Omitting" has to mean omitted from the *merged* inputs the tool receives.
|
||||
* Skipping the assignment only removes it from the mapper's return, which the
|
||||
* executor then merges over the raw subBlock string — so the typo reaches Splunk
|
||||
* anyway. Every assertion here therefore reads `mergedInputs`, not `mapParams`.
|
||||
*/
|
||||
it.each(['abc', 'twenty', '12px'])('omits an unparseable Max Results (%s)', (count) => {
|
||||
const merged = mergedInputs({ operation: 'splunk_list_indexes', count })
|
||||
it.each(['abc', 'twenty', '12px', '1,000', '50 rows', '<start.count>'])(
|
||||
'erases an unparseable Max Results (%s) from the merged inputs',
|
||||
(count) => {
|
||||
const merged = mergedInputs({ operation: 'splunk_list_indexes', count })
|
||||
|
||||
expect(merged.count).not.toBe(Number.NaN)
|
||||
expect(mapParams({ operation: 'splunk_list_indexes', count })).not.toHaveProperty('count')
|
||||
})
|
||||
expect(merged.count).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('omits an unparseable value on every numeric field it maps', () => {
|
||||
const result = mapParams({
|
||||
it('erases an unparseable value on every numeric field it maps', () => {
|
||||
const merged = mergedInputs({
|
||||
operation: 'splunk_dispatch_saved_search',
|
||||
savedSearchName: 'Errors',
|
||||
dispatchMaxCount: 'abc',
|
||||
dispatchMaxCount: '1,000',
|
||||
dispatchMaxTime: 'abc',
|
||||
dispatchTtl: 'abc',
|
||||
dispatchTtl: '30 days',
|
||||
offset: 'abc',
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('dispatchMaxCount')
|
||||
expect(result).not.toHaveProperty('dispatchMaxTime')
|
||||
expect(result).not.toHaveProperty('dispatchTtl')
|
||||
expect(result).not.toHaveProperty('offset')
|
||||
expect(merged.dispatchMaxCount).toBeUndefined()
|
||||
expect(merged.dispatchMaxTime).toBeUndefined()
|
||||
expect(merged.dispatchTtl).toBeUndefined()
|
||||
expect(merged.offset).toBeUndefined()
|
||||
})
|
||||
|
||||
it('erases an unparseable Max Stored Results on both search operations', () => {
|
||||
for (const operation of ['splunk_run_search', 'splunk_create_search_job']) {
|
||||
const merged = mergedInputs({
|
||||
operation,
|
||||
search: 'index=main',
|
||||
autoCancel: '5 minutes',
|
||||
maxCount: '1,000',
|
||||
})
|
||||
|
||||
expect(merged.autoCancel).toBeUndefined()
|
||||
expect(merged.maxCount).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The erased value must actually disappear from the wire, not serialize as the
|
||||
* string `'undefined'`.
|
||||
*/
|
||||
it('keeps an erased numeric field out of the request the tool builds', () => {
|
||||
const merged = mergedInputs({ operation: 'splunk_list_indexes', count: '1,000', offset: '10' })
|
||||
|
||||
expect(
|
||||
buildSplunkUrl({ baseUrl: 'https://splunk.example.com:8089' }, '/data/indexes', {
|
||||
count: merged.count as number | undefined,
|
||||
offset: merged.offset as number | undefined,
|
||||
})
|
||||
).toBe('https://splunk.example.com:8089/services/data/indexes?offset=10&output_mode=json')
|
||||
|
||||
expect(buildSplunkFormBody({ max_count: merged.count as number | undefined })).toBe('')
|
||||
})
|
||||
|
||||
it('still coerces the numeric forms it is given', () => {
|
||||
@@ -229,4 +268,56 @@ describe('SplunkBlock outputs', () => {
|
||||
expect(SplunkBlock.outputs).toHaveProperty('total')
|
||||
expect(SplunkBlock.outputs).toHaveProperty('offset')
|
||||
})
|
||||
|
||||
/**
|
||||
* The job entry documents this pair as bare numbers, unlike the ISO-string
|
||||
* `earliestTime`/`latestTime`, and the block's union output must agree with the
|
||||
* tool or the workflow is promised the wrong type.
|
||||
*/
|
||||
it('types the epoch search time bounds as numbers', () => {
|
||||
expect(SplunkBlock.outputs.searchEarliestTime).toMatchObject({ type: 'number' })
|
||||
expect(SplunkBlock.outputs.searchLatestTime).toMatchObject({ type: 'number' })
|
||||
})
|
||||
|
||||
/**
|
||||
* `[{type, text}]` holds for the search and job-control operations, but Get
|
||||
* Search Job projects the job entry's `messages` object. One shared union
|
||||
* output cannot promise the array shape for all of them.
|
||||
*/
|
||||
it('does not promise an array shape that get_search_job does not return', () => {
|
||||
const description = String(SplunkBlock.outputs.messages.description)
|
||||
|
||||
expect(description).toMatch(/Get Search Job/)
|
||||
expect(description).toMatch(/object/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SplunkBlockMeta skills', () => {
|
||||
it('suggests skills grounded in the operations the block exposes', () => {
|
||||
const skills = SplunkBlockMeta.skills
|
||||
|
||||
expect(skills?.length).toBeGreaterThanOrEqual(3)
|
||||
for (const skill of skills ?? []) {
|
||||
expect(skill.name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/)
|
||||
expect(skill.description.trim()).not.toBe('')
|
||||
expect(skill.content.trim()).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('gives every skill a distinct name', () => {
|
||||
const names = (SplunkBlockMeta.skills ?? []).map((skill) => skill.name)
|
||||
expect(new Set(names).size).toBe(names.length)
|
||||
})
|
||||
|
||||
/**
|
||||
* Run Search applies no Sim-side `max_count`, so a skill that tells the model
|
||||
* "at most 1000 rows by default" states a bound that does not exist.
|
||||
*/
|
||||
it('does not claim a row cap Run Search no longer applies', () => {
|
||||
const skill = SplunkBlockMeta.skills?.find((entry) => entry.name === 'search-splunk-logs')
|
||||
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.content).not.toMatch(/1000/)
|
||||
expect(skill?.content).toMatch(/cannot page/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,12 +28,16 @@ function toSplunkToggle(value: unknown): boolean | undefined {
|
||||
* An untouched subBlock resolves to `null` and an empty one to `''`; both are
|
||||
* omissions rather than zeros, so neither may reach `Number()` (which reads both
|
||||
* as `0`).
|
||||
*
|
||||
* The key is always written, never skipped. The executor merges this mapper's
|
||||
* return *over* the raw serialized subBlock values, so a key left unwritten keeps
|
||||
* the raw string (`'1,000'`) and forwards the typo to Splunk verbatim — the
|
||||
* opposite of omitting it. Writing `undefined` erases it instead, and both
|
||||
* `buildSplunkFormBody` and `buildSplunkUrl` drop nullish fields from the request.
|
||||
*/
|
||||
function assignSplunkNumber(target: Record<string, unknown>, key: string, value: unknown): void {
|
||||
if (value == null || value === '') return
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return
|
||||
target[key] = parsed
|
||||
const parsed = value == null || value === '' ? Number.NaN : Number(value)
|
||||
target[key] = Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
export const SplunkBlock: BlockConfig<SplunkResponse> = {
|
||||
@@ -597,7 +601,11 @@ Examples:
|
||||
resultCount: { type: 'number', description: 'Number of result rows returned' },
|
||||
preview: { type: 'boolean', description: 'Whether the results are previews' },
|
||||
initOffset: { type: 'number', description: 'Offset of the first returned row' },
|
||||
messages: { type: 'json', description: 'Messages returned with the response ([{type, text}])' },
|
||||
messages: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Messages returned with the response. An array of {type, text} for the search and job-control operations; Get Search Job instead returns the job entry messages object.',
|
||||
},
|
||||
sid: { type: 'string', description: 'Search ID of the job' },
|
||||
label: { type: 'string', description: 'Custom name of the search job' },
|
||||
dispatchState: { type: 'string', description: 'Current state of the search job' },
|
||||
@@ -620,12 +628,12 @@ Examples:
|
||||
earliestTime: { type: 'string', description: 'Earliest time bound of the job' },
|
||||
latestTime: { type: 'string', description: 'Latest time bound of the job' },
|
||||
searchEarliestTime: {
|
||||
type: 'string',
|
||||
description: 'Earliest time as specified in the search command',
|
||||
type: 'number',
|
||||
description: 'Earliest time as specified in the search command, as an epoch timestamp',
|
||||
},
|
||||
searchLatestTime: {
|
||||
type: 'string',
|
||||
description: 'Latest time as specified in the search command',
|
||||
type: 'number',
|
||||
description: 'Latest time as specified in the search command, as an epoch timestamp',
|
||||
},
|
||||
savedSearches: {
|
||||
type: 'json',
|
||||
@@ -681,6 +689,50 @@ Examples:
|
||||
export const SplunkBlockMeta = {
|
||||
tags: ['monitoring', 'data-analytics'],
|
||||
url: 'https://www.splunk.com',
|
||||
skills: [
|
||||
{
|
||||
name: 'search-splunk-logs',
|
||||
description: 'Answer a question about production behavior by running an SPL search.',
|
||||
content:
|
||||
'# Search Splunk Logs\n\nTurn a question about production into an SPL search and answer from the rows.\n\n## Steps\n1. Write a single SPL search scoped to one index and a bounded time range (for example `index=main error earliest=-1h`).\n2. Run the run search operation, which executes the search synchronously and returns the rows in one call.\n3. Read resultCount and results to gather the evidence.\n4. Summarize what the rows show, quoting the fields that matter.\n\n## Notes\nRun search buffers its whole result set in one response and cannot page. For a larger result set, create a search job and page through get search results with offset.\n\n## Output\nReturn the SPL that was run, the row count, and a short answer to the question.',
|
||||
},
|
||||
{
|
||||
name: 'long-running-search-job',
|
||||
description: 'Dispatch a long Splunk search, poll it to completion, then page the results.',
|
||||
content:
|
||||
'# Long-Running Search Job\n\nRun a search that is too slow for a synchronous call.\n\n## Steps\n1. Create a search job with the SPL and time range. Keep the returned sid.\n2. Poll get search job with that sid until dispatchState is DONE. Check isFailed and isZombie on each poll and stop if either is true.\n3. Fetch results with get search results, paging with count and offset until the rows are exhausted.\n4. Cancel the job when abandoning it early so the result cache is released.\n\n## Output\nReport the sid, the final dispatch state, the number of rows fetched, and the summarized findings.',
|
||||
},
|
||||
{
|
||||
name: 'triage-fired-alerts',
|
||||
description: 'Pull currently firing Splunk alerts and turn them into a triage summary.',
|
||||
content:
|
||||
'# Triage Fired Alerts\n\nTurn unexpired Splunk alerts into an actionable summary.\n\n## Steps\n1. List fired alerts to get every saved search with triggered alerts and its trigger count.\n2. For the noisiest saved searches, get fired alerts by name to read the individual instances with their severity, sid, and trigger time.\n3. Group the instances by saved search and severity, and rank by trigger count.\n4. Have an agent write a short triage note naming what is firing, how often, and what to look at first.\n\n## Output\nReturn the ranked alert list with trigger counts and the triage note.',
|
||||
},
|
||||
{
|
||||
name: 'run-saved-search',
|
||||
description: 'Dispatch an existing Splunk saved search and report its results.',
|
||||
content:
|
||||
'# Run Saved Search\n\nExecute a saved search that already encodes the right SPL.\n\n## Steps\n1. List saved searches, or get one by name, to confirm the search exists and read its SPL and schedule.\n2. Dispatch the saved search. Set trigger actions only when the alert actions should really fire.\n3. Poll get search job with the returned sid until the job is done.\n4. Fetch and summarize the results.\n\n## Output\nReturn the saved search name, the sid of the dispatched job, and a summary of the rows it produced.',
|
||||
},
|
||||
{
|
||||
name: 'index-capacity-report',
|
||||
description: 'Report on Splunk index size, retention, and event volume.',
|
||||
content:
|
||||
'# Index Capacity Report\n\nCheck which indexes are close to their limits.\n\n## Steps\n1. List indexes to read name, datatype, totalEventCount, currentDBSizeMB, maxTotalDataSizeMB, and frozenTimePeriodInSecs.\n2. Compute how full each index is against its maximum data size.\n3. Flag indexes above a threshold, and any whose retention window is shorter than the team expects.\n4. Page with count and offset when the instance has more indexes than one page returns.\n\n## Output\nReturn a table of indexes with size, usage percentage, and retention, plus the flagged entries.',
|
||||
},
|
||||
{
|
||||
name: 'audit-saved-search-hygiene',
|
||||
description: 'Inventory Splunk saved searches and flag disabled or stale scheduled ones.',
|
||||
content:
|
||||
'# Audit Saved Search Hygiene\n\nFind saved searches that no longer earn their schedule.\n\n## Steps\n1. List saved searches, paging with count and offset until total is covered.\n2. Read disabled, isScheduled, cronSchedule, and nextScheduledTime on each entry.\n3. Flag scheduled searches that are disabled, searches with no next scheduled time, and duplicate SPL across entries.\n4. Write the cleanup candidates somewhere durable, such as a table or a file.\n\n## Output\nReturn the counts by category and the list of cleanup candidates with the reason each was flagged.',
|
||||
},
|
||||
{
|
||||
name: 'app-inventory-check',
|
||||
description: 'Inventory the apps installed on a Splunk instance and flag disabled ones.',
|
||||
content:
|
||||
'# App Inventory Check\n\nRecord what is installed on the Splunk instance.\n\n## Steps\n1. List apps to read name, label, version, author, disabled, and configured.\n2. Flag apps that are installed but disabled, and apps that are not configured.\n3. Compare the versions against the versions the team expects to be running.\n\n## Output\nReturn the app inventory with versions and the list of disabled or unconfigured apps.',
|
||||
},
|
||||
],
|
||||
templates: [
|
||||
{
|
||||
icon: SplunkIcon,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.unmock('@/blocks/registry')
|
||||
|
||||
import { PASSWORD_MASKED_SUBBLOCK_TYPES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/password-mask'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
|
||||
/**
|
||||
* Fields that hold a credential and must stay concealed in the editor. Listed
|
||||
* explicitly so removing the flag to silence the audit below is itself a
|
||||
* failure — masking a secret is the point, not passing the check.
|
||||
*/
|
||||
const FIELDS_REQUIRING_MASKING: ReadonlyArray<{ block: string; subBlock: string }> = [
|
||||
{ block: 'pi', subBlock: 'privateKey' },
|
||||
{ block: 'sftp', subBlock: 'privateKey' },
|
||||
{ block: 'ssh', subBlock: 'privateKey' },
|
||||
{ block: 'secrets_manager', subBlock: 'secretValue' },
|
||||
{ block: 'kalshi', subBlock: 'privateKey' },
|
||||
{ block: 'sts', subBlock: 'webIdentityToken' },
|
||||
{ block: 'sts', subBlock: 'samlAssertion' },
|
||||
{ block: 'browser_use', subBlock: 'variables' },
|
||||
]
|
||||
|
||||
const maskedTypes = new Set<string>(PASSWORD_MASKED_SUBBLOCK_TYPES)
|
||||
|
||||
const SUB_BLOCK_COMPONENTS_DIR = join(
|
||||
import.meta.dirname,
|
||||
'..',
|
||||
'app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
|
||||
)
|
||||
|
||||
/**
|
||||
* The renderer file backing each masking sub-block type, relative to the
|
||||
* sub-block components directory. The exhaustive `Record` is the compile-time
|
||||
* gate: adding a type to `PASSWORD_MASKED_SUBBLOCK_TYPES` fails type-check until
|
||||
* its renderer is registered here and therefore audited below.
|
||||
*/
|
||||
const RENDERER_SOURCES: Record<(typeof PASSWORD_MASKED_SUBBLOCK_TYPES)[number], string> = {
|
||||
'short-input': 'short-input/short-input.tsx',
|
||||
'long-input': 'long-input/long-input.tsx',
|
||||
code: 'code/code.tsx',
|
||||
table: 'table/table.tsx',
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact form every renderer must use to decide masking: a `shouldMask`
|
||||
* binding whose entire right-hand side is a call to the shared policy helper.
|
||||
* Anything else — an extra conjunct, a locally recomputed predicate, an inlined
|
||||
* `password && !isFocused` — fails to match.
|
||||
*/
|
||||
const SHARED_MASK_DECISION = /const shouldMask = shouldMaskSecretValue\(\{[^})]*\}\)\n/
|
||||
|
||||
/**
|
||||
* The whole `shouldMask` statement, however many lines it spans: the declaring
|
||||
* line plus every continuation line, stopping at the next statement or comment.
|
||||
* A single-line scan would miss a conjunct wrapped onto its own line.
|
||||
*/
|
||||
const MASK_STATEMENT = /const shouldMask\b[^\n]*(?:\n(?!\s*(?:const |return |\/\*|\*)).*)*/g
|
||||
|
||||
/**
|
||||
* Signals a renderer must never mix into its masking decision. Workflow search
|
||||
* deliberately does not reveal a secret, so no search-derived value may reach
|
||||
* `shouldMask`.
|
||||
*/
|
||||
const FORBIDDEN_MASK_INPUTS = [
|
||||
'isSearchHighlighted',
|
||||
'getValidWorkflowSearchRange',
|
||||
'workflowSearchHighlight',
|
||||
'activeSearchTarget',
|
||||
]
|
||||
|
||||
function readRendererSource(relativePath: string): string {
|
||||
return readFileSync(join(SUB_BLOCK_COMPONENTS_DIR, relativePath), 'utf8')
|
||||
}
|
||||
|
||||
describe('password masking coverage', () => {
|
||||
it('only flags password on sub-block types whose renderer masks', () => {
|
||||
const unmasked: string[] = []
|
||||
|
||||
for (const block of getAllBlocks()) {
|
||||
for (const subBlock of block.subBlocks) {
|
||||
if (subBlock.password && !maskedTypes.has(subBlock.type)) {
|
||||
unmasked.push(`${block.type}.${subBlock.id} (type: ${subBlock.type})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(unmasked).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps every known credential field flagged for masking', () => {
|
||||
const blocksByType = new Map(getAllBlocks().map((block) => [block.type, block]))
|
||||
|
||||
for (const { block, subBlock } of FIELDS_REQUIRING_MASKING) {
|
||||
const config = blocksByType.get(block)
|
||||
expect(config, `block ${block} is missing from the registry`).toBeDefined()
|
||||
|
||||
const field = config?.subBlocks.find((candidate) => candidate.id === subBlock)
|
||||
expect(field, `${block}.${subBlock} is missing from the block config`).toBeDefined()
|
||||
expect(field?.password, `${block}.${subBlock} must be masked`).toBe(true)
|
||||
expect(maskedTypes.has(field?.type ?? ''), `${block}.${subBlock} renders in plaintext`).toBe(
|
||||
true
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('password reveal policy', () => {
|
||||
it.each(PASSWORD_MASKED_SUBBLOCK_TYPES)(
|
||||
'derives the %s mask decision from the shared policy helper',
|
||||
(type) => {
|
||||
const source = readRendererSource(RENDERER_SOURCES[type])
|
||||
|
||||
expect(
|
||||
source.includes('shouldMaskSecretValue') && source.includes("components/password-mask'"),
|
||||
`${type} must import shouldMaskSecretValue from the password-mask module`
|
||||
).toBe(true)
|
||||
|
||||
const decisions = source.match(new RegExp(SHARED_MASK_DECISION, 'g')) ?? []
|
||||
expect(decisions, `${type} must declare exactly one shared shouldMask decision`).toHaveLength(
|
||||
1
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(PASSWORD_MASKED_SUBBLOCK_TYPES)(
|
||||
'keeps workflow-search signals away from the %s mask decision',
|
||||
(type) => {
|
||||
const source = readRendererSource(RENDERER_SOURCES[type])
|
||||
const statements = source.match(MASK_STATEMENT) ?? []
|
||||
|
||||
expect(statements.length, `${type} never declares shouldMask`).toBeGreaterThan(0)
|
||||
|
||||
const leaked = statements.filter((statement) =>
|
||||
FORBIDDEN_MASK_INPUTS.some((signal) => statement.includes(signal))
|
||||
)
|
||||
expect(leaked, `${type} lets a workflow-search signal unmask a secret`).toEqual([])
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -342,6 +342,14 @@ export interface SubBlockConfig {
|
||||
max?: number
|
||||
columns?: string[]
|
||||
placeholder?: string
|
||||
/**
|
||||
* Conceals the stored value in the editor until the field is focused.
|
||||
*
|
||||
* Honoured only by the `short-input`, `long-input`, `code`, and `table`
|
||||
* renderers (`PASSWORD_MASKED_SUBBLOCK_TYPES`). On any other type it is a
|
||||
* silent no-op that leaves the secret in plaintext, so a new usage must teach
|
||||
* that renderer to mask. `blocks/password-masking.test.ts` enforces this.
|
||||
*/
|
||||
password?: boolean
|
||||
readOnly?: boolean
|
||||
showCopyButton?: boolean
|
||||
|
||||
@@ -792,7 +792,10 @@ const queryIndicatorsSchema = baseRequestSchema
|
||||
.number()
|
||||
.int()
|
||||
.min(1, 'Limit must be at least 1')
|
||||
.max(500, 'Limit must be at most 500')
|
||||
.max(
|
||||
500,
|
||||
'Sim caps this request at 500 indicators; CrowdStrike publishes no limit for this endpoint'
|
||||
)
|
||||
.optional(),
|
||||
offset: z.number().int().nonnegative('Offset must be 0 or greater').optional(),
|
||||
after: nonBlankQuerySchema('After cursor'),
|
||||
|
||||
@@ -71,6 +71,13 @@ export const sqlRowsResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
rows: z.array(z.unknown()),
|
||||
rowCount: z.number(),
|
||||
/**
|
||||
* Present only when the driver returned more than the route was willing to
|
||||
* serialize. Absent means the recordset is complete, so a caller that ignores
|
||||
* these two fields still reads a whole result correctly.
|
||||
*/
|
||||
truncated: z.boolean().optional(),
|
||||
truncationReason: z.string().optional(),
|
||||
})
|
||||
|
||||
export const mongoDocumentsResponseSchema = z
|
||||
|
||||
@@ -3947,7 +3947,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Get Zone Settings",
|
||||
"description": "Gets all settings for a zone including SSL mode, caching level, and security settings."
|
||||
"description": "Reads zone settings such as SSL mode, minimum TLS version, security level, and caching level. Cloudflare retired the endpoint that read every setting in one request, so each setting is read individually — name the ones you need to keep the read small. Defaults to ${DEFAULT_ZONE_SETTING_IDS.join(', ')}."
|
||||
},
|
||||
{
|
||||
"name": "Update Zone Setting",
|
||||
@@ -4019,7 +4019,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Update Access Application",
|
||||
"description": "Updates a Cloudflare Access (Zero Trust) application. This replaces the application definition rather than merging it, so send every field the application should keep — anything you omit reverts to its default, which can widen or break access. Read the current configuration with \"Get Access Application\" first. Requires an API token with Account Access: Apps and Policies Edit."
|
||||
"description": "Updates a Cloudflare Access (Zero Trust) application. Cloudflare does not document merge behavior for this PUT, so treat it as a replace: send every field the application should keep, because an omitted field may revert to its default and widen or break access. Read the current configuration with \"Get Access Application\" first. Requires an API token with Account Access: Apps and Policies Edit."
|
||||
},
|
||||
{
|
||||
"name": "Delete Access Application",
|
||||
@@ -4035,7 +4035,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Update Access Policy",
|
||||
"description": "Updates a Cloudflare Access (Zero Trust) policy on an application. This replaces the policy definition rather than merging it, so send every rule the policy should keep — omitted exclude or require rules are dropped, which can widen who gets in. The change applies to live traffic immediately. Read the current policy with \"List Access Policies\" first. Requires an API token with Account Access: Apps and Policies Edit."
|
||||
"description": "Updates a Cloudflare Access (Zero Trust) policy on an application. Cloudflare does not document merge behavior for this PUT, so treat it as a replace: send every rule the policy should keep, because an omitted exclude or require rule may be dropped and widen who gets in. The change applies to live traffic immediately. Read the current policy with \"List Access Policies\" first. Requires an API token with Account Access: Apps and Policies Edit."
|
||||
},
|
||||
{
|
||||
"name": "Delete Access Policy",
|
||||
@@ -19815,7 +19815,7 @@
|
||||
"operations": [
|
||||
{
|
||||
"name": "Run Search",
|
||||
"description": "Run an SPL search synchronously and return its results in a single call (oneshot mode). Use for short searches; use Create Search Job for long-running ones."
|
||||
"description": "Run an SPL search synchronously and return its results in a single call (oneshot mode). A oneshot search buffers the whole result set in one response with no paging, so use it for short searches; for anything large use Create Search Job with Get Search Results, which defaults to 100 rows and pages with offset."
|
||||
},
|
||||
{
|
||||
"name": "Create Search Job",
|
||||
|
||||
@@ -337,7 +337,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'User.ReadWrite.All',
|
||||
'Group.ReadWrite.All',
|
||||
'GroupMember.ReadWrite.All',
|
||||
'Directory.Read.All',
|
||||
'LicenseAssignment.Read.All',
|
||||
'LicenseAssignment.ReadWrite.All',
|
||||
'UserAuthenticationMethod.ReadWrite.All',
|
||||
'AuditLog.Read.All',
|
||||
|
||||
@@ -259,6 +259,7 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'User.ReadWrite.All': 'Read and write all user profiles',
|
||||
'GroupMember.ReadWrite.All': 'Read and write all group memberships',
|
||||
'Directory.Read.All': 'Read directory data',
|
||||
'LicenseAssignment.Read.All': 'Read license assignments and subscribed SKUs',
|
||||
'LicenseAssignment.ReadWrite.All': 'Assign and remove user licenses',
|
||||
'UserAuthenticationMethod.ReadWrite.All':
|
||||
'Read and reset authentication methods and passwords for all users',
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* These tests assert the seeded default reaching each tool for operations whose
|
||||
* control is deliberately not last, so re-introducing a collision goes red.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CloudflareBlock } from '@/blocks/blocks/cloudflare'
|
||||
import * as cloudflareTools from '@/tools/cloudflare'
|
||||
|
||||
@@ -341,6 +341,43 @@ describe('no hidden advanced control feeds an operation that cannot show it', ()
|
||||
|
||||
expect(leaks).toEqual([])
|
||||
})
|
||||
|
||||
/**
|
||||
* A subBlock can also be hidden by a guard on a field other than `operation`
|
||||
* (purge_cache's targets are hidden once `purge_everything` is yes). An
|
||||
* advanced control serializes on stored value alone, so the serializer never
|
||||
* reaches that guard either — the mapper has to clear it.
|
||||
*/
|
||||
it('clears every advanced control whose own guard field hides it', () => {
|
||||
const leaks: string[] = []
|
||||
|
||||
for (const subBlock of CloudflareBlock.subBlocks) {
|
||||
if (subBlock.mode !== 'advanced') continue
|
||||
const guard = (
|
||||
subBlock.condition as { and?: { field: string; value: unknown; not?: boolean } } | undefined
|
||||
)?.and
|
||||
if (!guard || guard.field === 'operation') continue
|
||||
|
||||
const hidingValue = guard.not === true ? String(guard.value) : '__guard_off__'
|
||||
|
||||
for (const operation of conditionOperations(subBlock)) {
|
||||
const tool = toolsByOperation.get(operation)
|
||||
if (!tool?.params || !(subBlock.id in tool.params)) continue
|
||||
|
||||
const mapped = mapFor(operation, {
|
||||
[guard.field]: hidingValue,
|
||||
[subBlock.id]: STALE,
|
||||
})
|
||||
if (mapped[subBlock.id] === STALE) {
|
||||
leaks.push(
|
||||
`"${subBlock.id}" reaches ${operation} when ${guard.field}=${hidingValue} hides it`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(leaks).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -630,3 +667,456 @@ describe('optional and per-API pagination params', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
/** Builds the Cloudflare v4 envelope every tool's transformResponse reads. */
|
||||
function envelope(result: unknown, success = true): Response {
|
||||
return new Response(JSON.stringify({ success, errors: [], messages: [], result }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('DNS analytics does not fabricate metrics Cloudflare did not return', () => {
|
||||
const tool = cloudflareTools.cloudflareDnsAnalyticsTool
|
||||
|
||||
it('passes min and max through instead of filling seven zeroes', async () => {
|
||||
// Cloudflare documents both as "currently always an empty object", so a
|
||||
// seven-field numeric block is fabricated telemetry.
|
||||
const out = (await tool.transformResponse!(
|
||||
envelope({ totals: { queryCount: 12 }, min: {}, max: {}, data: [], rows: 0 }),
|
||||
{} as never
|
||||
)) as { output: { min: unknown; max: unknown } }
|
||||
|
||||
expect(out.output.min).toEqual({})
|
||||
expect(out.output.max).toEqual({})
|
||||
})
|
||||
|
||||
it('reports an absent min or max as null rather than zeroes', async () => {
|
||||
const out = (await tool.transformResponse!(envelope({ totals: {} }), {} as never)) as {
|
||||
output: { min: unknown; max: unknown }
|
||||
}
|
||||
|
||||
expect(out.output.min).toBeNull()
|
||||
expect(out.output.max).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves an unrequested total absent instead of reading it as zero', async () => {
|
||||
const out = (await tool.transformResponse!(
|
||||
envelope({ totals: { queryCount: 12 } }),
|
||||
{} as never
|
||||
)) as { output: { totals: Record<string, unknown> } }
|
||||
|
||||
expect(out.output.totals.queryCount).toBe(12)
|
||||
expect(out.output.totals.responseTimeAvg).toBeUndefined()
|
||||
expect(out.output.totals.uncachedCount).toBeUndefined()
|
||||
})
|
||||
|
||||
it('declares min and max as opaque JSON, not a numeric object', () => {
|
||||
expect(tool.outputs?.min).toMatchObject({ type: 'json' })
|
||||
expect(tool.outputs?.max).toMatchObject({ type: 'json' })
|
||||
expect((tool.outputs?.min as { description: string }).description).toMatch(/empty object/i)
|
||||
})
|
||||
|
||||
it('keeps metrics optional in the block, matching the tool and the API', () => {
|
||||
const metrics = CloudflareBlock.subBlocks.find((sub) => sub.id === 'metrics')
|
||||
expect(metrics?.required).toBeUndefined()
|
||||
expect(tool.params.metrics.required).toBe(false)
|
||||
expect(tool.params.metrics.description).not.toMatch(/default metric set/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('purge cache refuses an ambiguous whole-zone purge', () => {
|
||||
const buildBody = cloudflareTools.cloudflarePurgeCacheTool.request.body!
|
||||
|
||||
it('throws when purge_everything is combined with a target list', () => {
|
||||
expect(() =>
|
||||
buildBody({
|
||||
zoneId: 'z1',
|
||||
apiKey,
|
||||
purge_everything: true,
|
||||
files: 'https://example.com/a.css',
|
||||
} as never)
|
||||
).toThrow(/cannot be combined with specific targets/)
|
||||
})
|
||||
|
||||
it('still purges everything when no targets were given', () => {
|
||||
expect(buildBody({ zoneId: 'z1', apiKey, purge_everything: true } as never)).toEqual({
|
||||
purge_everything: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults the block dropdown to purging specific targets', () => {
|
||||
const purgeEverything = CloudflareBlock.subBlocks.find((sub) => sub.id === 'purge_everything')
|
||||
expect((purgeEverything?.value as () => string)()).toBe('false')
|
||||
})
|
||||
|
||||
/**
|
||||
* `tags`, `hosts`, and `prefixes` are advanced, so the serializer emits their
|
||||
* stored value before evaluating the `purge_everything` guard that hides them.
|
||||
* A target typed before switching to a whole-zone purge therefore survived and
|
||||
* made the tool refuse the purge over a field the editor no longer renders.
|
||||
*/
|
||||
it('drops targets typed before the switch to a whole-zone purge', () => {
|
||||
const mapped = mapFor('purge_cache', {
|
||||
zoneId: 'z1',
|
||||
purge_everything: 'true',
|
||||
files: 'https://example.com/a.css',
|
||||
tags: 'homepage',
|
||||
hosts: 'example.com',
|
||||
prefixes: 'https://example.com/assets/',
|
||||
})
|
||||
|
||||
expect(mapped.files).toBeUndefined()
|
||||
expect(mapped.tags).toBeUndefined()
|
||||
expect(mapped.hosts).toBeUndefined()
|
||||
expect(mapped.prefixes).toBeUndefined()
|
||||
expect(buildBody(mapped as never)).toEqual({ purge_everything: true })
|
||||
})
|
||||
|
||||
it('still purges the named targets when purge everything is no', () => {
|
||||
const mapped = mapFor('purge_cache', {
|
||||
zoneId: 'z1',
|
||||
purge_everything: 'false',
|
||||
tags: 'homepage',
|
||||
})
|
||||
|
||||
expect(mapped.tags).toBe('homepage')
|
||||
expect(buildBody(mapped as never)).toEqual({ tags: ['homepage'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a rate limiting rule update keeps the fields the endpoint would reset', () => {
|
||||
const updateRule = cloudflareTools.cloudflareUpdateRateLimitRuleTool
|
||||
|
||||
const base = {
|
||||
zoneId: 'z1',
|
||||
rulesetId: 'rs1',
|
||||
ruleId: 'r1',
|
||||
apiKey,
|
||||
action: 'block',
|
||||
expression: 'true',
|
||||
characteristics: 'cf.colo.id,ip.src',
|
||||
period: 60,
|
||||
requestsPerPeriod: 100,
|
||||
}
|
||||
|
||||
/**
|
||||
* The rulesets update-rule endpoint replaces the whole rule definition, so a
|
||||
* custom block response and the reference tag have to be resent or Cloudflare
|
||||
* resets them — the block page comes back and the ref regenerates.
|
||||
*/
|
||||
it('forwards the custom mitigation response, reference tag, and logging', () => {
|
||||
const body = updateRule.request.body?.({
|
||||
...base,
|
||||
ref: 'api-throttle',
|
||||
actionParameters:
|
||||
'{"response":{"status_code":429,"content":"{\\"error\\":\\"rate limited\\"}","content_type":"application/json"}}',
|
||||
logging: '{"enabled":true}',
|
||||
} as never) as Record<string, unknown>
|
||||
|
||||
expect(body.ref).toBe('api-throttle')
|
||||
expect(body.action_parameters).toEqual({
|
||||
response: {
|
||||
status_code: 429,
|
||||
content: '{"error":"rate limited"}',
|
||||
content_type: 'application/json',
|
||||
},
|
||||
})
|
||||
expect(body.logging).toEqual({ enabled: true })
|
||||
})
|
||||
|
||||
it('declares the three fields as tool params so a caller can resend them', () => {
|
||||
expect(updateRule.params.ref).toBeDefined()
|
||||
expect(updateRule.params.actionParameters).toBeDefined()
|
||||
expect(updateRule.params.logging).toBeDefined()
|
||||
expect(updateRule.params.actionParameters.description).toMatch(/resets action_parameters/)
|
||||
expect(updateRule.params.ref.description).toMatch(/omitting it resets/)
|
||||
})
|
||||
|
||||
it('omits them entirely when the caller sends nothing', () => {
|
||||
const body = updateRule.request.body?.(base as never) as Record<string, unknown>
|
||||
|
||||
expect(body).not.toHaveProperty('ref')
|
||||
expect(body).not.toHaveProperty('action_parameters')
|
||||
expect(body).not.toHaveProperty('logging')
|
||||
})
|
||||
|
||||
it('offers all three in the block for the rate limiting update', () => {
|
||||
const shownFor = (id: string) =>
|
||||
CloudflareBlock.subBlocks.flatMap((subBlock) => {
|
||||
if (subBlock.id !== id) return []
|
||||
const condition = subBlock.condition
|
||||
if (!condition || typeof condition !== 'object' || !('field' in condition)) return []
|
||||
if (condition.field !== 'operation') return []
|
||||
const value = condition.value
|
||||
return Array.isArray(value) ? value.map(String) : [String(value)]
|
||||
})
|
||||
|
||||
expect(shownFor('ref')).toContain('update_rate_limit_rule')
|
||||
expect(shownFor('logging')).toContain('update_rate_limit_rule')
|
||||
expect(shownFor('rateLimitActionParameters')).toEqual(['update_rate_limit_rule'])
|
||||
})
|
||||
|
||||
/**
|
||||
* The WAF control named "Action Parameters" holds a managed-ruleset payload,
|
||||
* which is not what a rate limiting rule takes, so the two must stay separate
|
||||
* controls that map onto the same tool param.
|
||||
*/
|
||||
it('routes the rate limiting control onto the tool param without sharing the WAF one', () => {
|
||||
expect(
|
||||
mapFor('update_rate_limit_rule', {
|
||||
zoneId: 'z1',
|
||||
rulesetId: 'rs1',
|
||||
ruleId: 'r1',
|
||||
actionParameters: '{"id":"managed-1"}',
|
||||
rateLimitActionParameters: '{"response":{"status_code":429}}',
|
||||
}).actionParameters
|
||||
).toBe('{"response":{"status_code":429}}')
|
||||
|
||||
expect(
|
||||
mapFor('update_ruleset_rule', {
|
||||
zoneId: 'z1',
|
||||
rulesetId: 'rs1',
|
||||
ruleId: 'r1',
|
||||
actionParameters: '{"id":"managed-1"}',
|
||||
}).actionParameters
|
||||
).toBe('{"id":"managed-1"}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('zone settings are read through the endpoints Cloudflare still supports', () => {
|
||||
const tool = cloudflareTools.cloudflareGetZoneSettingsTool
|
||||
|
||||
function settingEnvelope(id: string, value: unknown) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
errors: [],
|
||||
messages: [],
|
||||
result: { id, value, editable: true, modified_on: '2026-01-01T00:00:00Z' },
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
/**
|
||||
* Cloudflare deprecated the batch `GET /zones/{zone_id}/settings` endpoint,
|
||||
* with end of life on 2027-03-31, and directs integrations at the per-setting
|
||||
* endpoint instead.
|
||||
*/
|
||||
it('does not declare the deprecated batch settings endpoint', () => {
|
||||
const declaredUrl = tool.request.url({ zoneId: 'z1', apiKey } as never)
|
||||
expect(declaredUrl).not.toMatch(/\/zones\/z1\/settings$/)
|
||||
expect(declaredUrl).toMatch(/\/zones\/z1\/settings\/[a-z0-9_]+$/)
|
||||
})
|
||||
|
||||
it('issues one request per setting against the per-setting endpoint', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockImplementation(async (input) => settingEnvelope(String(input).split('/').pop()!, 'on'))
|
||||
|
||||
await tool.directExecution!({ zoneId: 'z1', apiKey, settingIds: 'ssl,http3' } as never)
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([
|
||||
'https://api.cloudflare.com/client/v4/zones/z1/settings/ssl',
|
||||
'https://api.cloudflare.com/client/v4/zones/z1/settings/http3',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns each setting under the list shape the block already reads', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) =>
|
||||
settingEnvelope(String(input).split('/').pop()!, 'full')
|
||||
)
|
||||
|
||||
const out = (await tool.directExecution!({
|
||||
zoneId: 'z1',
|
||||
apiKey,
|
||||
settingIds: 'ssl',
|
||||
} as never)) as {
|
||||
success: boolean
|
||||
output: { settings: Array<Record<string, unknown>>; unreadable: unknown[] }
|
||||
}
|
||||
|
||||
expect(out.success).toBe(true)
|
||||
expect(out.output.settings).toEqual([
|
||||
{ id: 'ssl', value: 'full', editable: true, modified_on: '2026-01-01T00:00:00Z' },
|
||||
])
|
||||
expect(out.output.unreadable).toEqual([])
|
||||
})
|
||||
|
||||
/**
|
||||
* A plan-gated setting answers with an error rather than a value, and one
|
||||
* refusal must not lose the settings that did come back.
|
||||
*/
|
||||
it('reports a refused setting without dropping the readable ones', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const settingId = String(input).split('/').pop()!
|
||||
if (settingId === 'http3') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
errors: [{ code: 1006, message: 'Not available for this plan' }],
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
return settingEnvelope(settingId, 'full')
|
||||
})
|
||||
|
||||
const out = (await tool.directExecution!({
|
||||
zoneId: 'z1',
|
||||
apiKey,
|
||||
settingIds: 'ssl,http3',
|
||||
} as never)) as {
|
||||
success: boolean
|
||||
output: { settings: Array<{ id: string }>; unreadable: Array<{ id: string; error: string }> }
|
||||
}
|
||||
|
||||
expect(out.success).toBe(true)
|
||||
expect(out.output.settings.map((setting) => setting.id)).toEqual(['ssl'])
|
||||
expect(out.output.unreadable).toEqual([{ id: 'http3', error: 'Not available for this plan' }])
|
||||
})
|
||||
|
||||
it('fails only when nothing at all could be read', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ success: false, errors: [{ message: 'Invalid zone identifier' }] }),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
|
||||
const out = (await tool.directExecution!({
|
||||
zoneId: 'z1',
|
||||
apiKey,
|
||||
settingIds: 'ssl',
|
||||
} as never)) as { success: boolean; error?: string }
|
||||
|
||||
expect(out.success).toBe(false)
|
||||
expect(out.error).toBe('Invalid zone identifier')
|
||||
})
|
||||
|
||||
it('refuses an unbounded fan-out instead of issuing the requests', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
|
||||
const out = (await tool.directExecution!({
|
||||
zoneId: 'z1',
|
||||
apiKey,
|
||||
settingIds: Array.from({ length: 41 }, (_, index) => `setting_${index}`).join(','),
|
||||
} as never)) as { success: boolean; error?: string }
|
||||
|
||||
expect(out.success).toBe(false)
|
||||
expect(out.error).toMatch(/at most 40/)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the top-level priority field is described the way Cloudflare defines it', () => {
|
||||
/**
|
||||
* Cloudflare accepts the top-level `priority` for MX and URI records and
|
||||
* ignores it for every other type, SRV included — an SRV record carries its
|
||||
* priority inside the record content. Saying "MX and SRV" invites a model to
|
||||
* send a value Cloudflare drops while returning 200.
|
||||
*/
|
||||
it.each([
|
||||
['update_dns_record param', cloudflareTools.cloudflareUpdateDnsRecordTool.params.priority],
|
||||
[
|
||||
'update_dns_record output',
|
||||
cloudflareTools.cloudflareUpdateDnsRecordTool.outputs!.priority as { description: string },
|
||||
],
|
||||
[
|
||||
'list_dns_records output',
|
||||
(
|
||||
cloudflareTools.cloudflareListDnsRecordsTool.outputs!.records as never as {
|
||||
items: { properties: Record<string, { description: string }> }
|
||||
}
|
||||
).items.properties.priority,
|
||||
],
|
||||
])('%s does not claim SRV uses the top-level priority', (_name, described) => {
|
||||
expect(described.description).not.toMatch(/MX and SRV|MX\/SRV/)
|
||||
expect(described.description).toMatch(/URI/)
|
||||
})
|
||||
|
||||
it('keeps the block placeholder and input description off SRV too', () => {
|
||||
for (const subBlock of CloudflareBlock.subBlocks) {
|
||||
if (subBlock.id !== 'priority') continue
|
||||
expect(subBlock.placeholder).not.toMatch(/MX and SRV|MX\/SRV/)
|
||||
}
|
||||
expect(CloudflareBlock.inputs.priority.description).not.toMatch(/MX and SRV|MX\/SRV/)
|
||||
expect(CloudflareBlock.inputs.priority.description).toMatch(/URI/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a blank optional number never reaches a tool as zero', () => {
|
||||
/**
|
||||
* `Number('')` is `0`, and the DNS tools forward `priority`/`ttl` on presence
|
||||
* rather than truthiness — so a truthiness guard turned a blank advanced field
|
||||
* into a TTL of 0 (out of Cloudflare's 30-86400 range) and a priority of 0.
|
||||
*/
|
||||
it.each([
|
||||
['create_dns_record', 'ttl'],
|
||||
['create_dns_record', 'priority'],
|
||||
['list_zones', 'page'],
|
||||
['list_zones', 'per_page'],
|
||||
['dns_analytics', 'limit'],
|
||||
])('%s leaves a blank %s undefined rather than 0', (operation, field) => {
|
||||
for (const blank of ['', null]) {
|
||||
const mapped = mapFor(operation, { zoneId: 'z1', accountId: 'a1', [field]: blank })
|
||||
expect(mapped[field], `${operation}.${field} from ${JSON.stringify(blank)}`).toBeUndefined()
|
||||
expect(mapped[field]).not.toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The mapper handing on `''` is what produces the `0`: the DNS tools forward
|
||||
* on `!== undefined`, and `Number('')` is `0`. This walks that last step so
|
||||
* the assertion is about the request Cloudflare actually receives.
|
||||
*/
|
||||
it.each([
|
||||
['create_dns_record', cloudflareTools.cloudflareCreateDnsRecordTool],
|
||||
['update_dns_record', cloudflareTools.cloudflareUpdateDnsRecordTool],
|
||||
])('%s sends no ttl or priority at all when both are blank', (operation, tool) => {
|
||||
const mapped = mapFor(operation, {
|
||||
zoneId: 'z1',
|
||||
recordId: 'rec1',
|
||||
name: 'mail.example.com',
|
||||
content: 'mx.example.com',
|
||||
recordType: 'MX',
|
||||
updateRecordType: 'MX',
|
||||
ttl: '',
|
||||
priority: '',
|
||||
})
|
||||
|
||||
const body = tool.request.body?.(mapped as never) as Record<string, unknown>
|
||||
expect(body).not.toHaveProperty('ttl')
|
||||
expect(body).not.toHaveProperty('priority')
|
||||
expect(body.ttl).not.toBe(0)
|
||||
expect(body.priority).not.toBe(0)
|
||||
})
|
||||
|
||||
it('still coerces a supplied value to a number', () => {
|
||||
expect(mapFor('create_dns_record', { zoneId: 'z1', ttl: '3600' }).ttl).toBe(3600)
|
||||
expect(mapFor('create_dns_record', { zoneId: 'z1', priority: '10' }).priority).toBe(10)
|
||||
expect(mapFor('list_zones', { page: '2', per_page: '50' })).toMatchObject({
|
||||
page: 2,
|
||||
per_page: 50,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('list transforms survive a non-array result', () => {
|
||||
it.each([
|
||||
['list_zones', cloudflareTools.cloudflareListZonesTool, 'zones'],
|
||||
['list_dns_records', cloudflareTools.cloudflareListDnsRecordsTool, 'records'],
|
||||
['list_certificates', cloudflareTools.cloudflareListCertificatesTool, 'certificates'],
|
||||
])('%s returns an empty list rather than throwing', async (_name, tool, key) => {
|
||||
// A success:true body whose result is an object (or null) must not throw a
|
||||
// raw TypeError out of transformResponse.
|
||||
const out = (await tool.transformResponse!(envelope({ unexpected: true }), {} as never)) as {
|
||||
output: Record<string, unknown>
|
||||
}
|
||||
expect(out.output[key]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ export const createRateLimitRuleTool: ToolConfig<
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Comma-separated counting characteristics. cf.colo.id is mandatory, plus exactly one of ip.src or cf.unique_visitor_id. Example: cf.colo.id,ip.src',
|
||||
'Comma-separated counting characteristics. cf.colo.id is mandatory. ip.src and cf.unique_visitor_id are mutually exclusive — include at most one. Example: cf.colo.id,ip.src',
|
||||
},
|
||||
period: {
|
||||
type: 'number',
|
||||
|
||||
@@ -29,7 +29,7 @@ export const createZoneTool: ToolConfig<CloudflareCreateZoneParams, CloudflareCr
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Zone type: "full" (Cloudflare manages DNS), "partial" (CNAME setup), or "secondary" (secondary DNS)',
|
||||
'Zone type: "full" (Cloudflare manages DNS), "partial" (CNAME setup), or "secondary" (secondary DNS). Cloudflare also defines "internal", which is not creatable through this tool',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
@@ -157,7 +157,7 @@ export const createZoneTool: ToolConfig<CloudflareCreateZoneParams, CloudflareCr
|
||||
description: 'Zone status (initializing, pending, active, moved)',
|
||||
},
|
||||
paused: { type: 'boolean', description: 'Whether the zone is paused' },
|
||||
type: { type: 'string', description: 'Zone type (full, partial, or secondary)' },
|
||||
type: { type: 'string', description: 'Zone type (full, partial, secondary, or internal)' },
|
||||
name_servers: {
|
||||
type: 'array',
|
||||
description: 'Assigned Cloudflare name servers',
|
||||
|
||||
@@ -41,7 +41,7 @@ export const dnsAnalyticsTool: ToolConfig<
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Comma-separated metrics to retrieve (e.g., "queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedian,responseTime90th,responseTime99th"). Optional — Cloudflare returns its default metric set when it is omitted',
|
||||
'Comma-separated metrics to retrieve (e.g., "queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedian,responseTime90th,responseTime99th"). Optional in the API',
|
||||
},
|
||||
dimensions: {
|
||||
type: 'string',
|
||||
@@ -105,33 +105,9 @@ export const dnsAnalyticsTool: ToolConfig<
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
totals: {
|
||||
queryCount: 0,
|
||||
uncachedCount: 0,
|
||||
staleCount: 0,
|
||||
responseTimeAvg: 0,
|
||||
responseTimeMedian: 0,
|
||||
responseTime90th: 0,
|
||||
responseTime99th: 0,
|
||||
},
|
||||
min: {
|
||||
queryCount: 0,
|
||||
uncachedCount: 0,
|
||||
staleCount: 0,
|
||||
responseTimeAvg: 0,
|
||||
responseTimeMedian: 0,
|
||||
responseTime90th: 0,
|
||||
responseTime99th: 0,
|
||||
},
|
||||
max: {
|
||||
queryCount: 0,
|
||||
uncachedCount: 0,
|
||||
staleCount: 0,
|
||||
responseTimeAvg: 0,
|
||||
responseTimeMedian: 0,
|
||||
responseTime90th: 0,
|
||||
responseTime99th: 0,
|
||||
},
|
||||
totals: {},
|
||||
min: null,
|
||||
max: null,
|
||||
data: [],
|
||||
data_lag: 0,
|
||||
rows: 0,
|
||||
@@ -153,33 +129,22 @@ export const dnsAnalyticsTool: ToolConfig<
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
/**
|
||||
* Cloudflare only populates the metrics that were requested. Passing the
|
||||
* block through untouched keeps an unrequested metric absent instead of
|
||||
* reporting it as a measured zero.
|
||||
*/
|
||||
totals: {
|
||||
queryCount: result?.totals?.queryCount ?? 0,
|
||||
uncachedCount: result?.totals?.uncachedCount ?? 0,
|
||||
staleCount: result?.totals?.staleCount ?? 0,
|
||||
responseTimeAvg: result?.totals?.responseTimeAvg ?? 0,
|
||||
responseTimeMedian: result?.totals?.responseTimeMedian ?? 0,
|
||||
responseTime90th: result?.totals?.responseTime90th ?? 0,
|
||||
responseTime99th: result?.totals?.responseTime99th ?? 0,
|
||||
},
|
||||
min: {
|
||||
queryCount: result?.min?.queryCount ?? 0,
|
||||
uncachedCount: result?.min?.uncachedCount ?? 0,
|
||||
staleCount: result?.min?.staleCount ?? 0,
|
||||
responseTimeAvg: result?.min?.responseTimeAvg ?? 0,
|
||||
responseTimeMedian: result?.min?.responseTimeMedian ?? 0,
|
||||
responseTime90th: result?.min?.responseTime90th ?? 0,
|
||||
responseTime99th: result?.min?.responseTime99th ?? 0,
|
||||
},
|
||||
max: {
|
||||
queryCount: result?.max?.queryCount ?? 0,
|
||||
uncachedCount: result?.max?.uncachedCount ?? 0,
|
||||
staleCount: result?.max?.staleCount ?? 0,
|
||||
responseTimeAvg: result?.max?.responseTimeAvg ?? 0,
|
||||
responseTimeMedian: result?.max?.responseTimeMedian ?? 0,
|
||||
responseTime90th: result?.max?.responseTime90th ?? 0,
|
||||
responseTime99th: result?.max?.responseTime99th ?? 0,
|
||||
queryCount: result?.totals?.queryCount,
|
||||
uncachedCount: result?.totals?.uncachedCount,
|
||||
staleCount: result?.totals?.staleCount,
|
||||
responseTimeAvg: result?.totals?.responseTimeAvg,
|
||||
responseTimeMedian: result?.totals?.responseTimeMedian,
|
||||
responseTime90th: result?.totals?.responseTime90th,
|
||||
responseTime99th: result?.totals?.responseTime99th,
|
||||
},
|
||||
min: result?.min ?? null,
|
||||
max: result?.max ?? null,
|
||||
data:
|
||||
result?.data?.map((entry) => ({
|
||||
dimensions: entry.dimensions ?? [],
|
||||
@@ -203,11 +168,25 @@ export const dnsAnalyticsTool: ToolConfig<
|
||||
outputs: {
|
||||
totals: {
|
||||
type: 'object',
|
||||
description: 'Aggregate DNS analytics totals for the entire queried period',
|
||||
description:
|
||||
'Aggregate DNS analytics totals for the entire queried period. Only the metrics that were requested are present.',
|
||||
properties: {
|
||||
queryCount: { type: 'number', description: 'Total number of DNS queries' },
|
||||
uncachedCount: { type: 'number', description: 'Number of uncached DNS queries' },
|
||||
staleCount: { type: 'number', description: 'Number of stale DNS queries' },
|
||||
queryCount: {
|
||||
type: 'number',
|
||||
description: 'Total number of DNS queries. Absent when queryCount was not requested',
|
||||
optional: true,
|
||||
},
|
||||
uncachedCount: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Number of uncached DNS queries. Absent when uncachedCount was not requested',
|
||||
optional: true,
|
||||
},
|
||||
staleCount: {
|
||||
type: 'number',
|
||||
description: 'Number of stale DNS queries. Absent when staleCount was not requested',
|
||||
optional: true,
|
||||
},
|
||||
responseTimeAvg: {
|
||||
type: 'number',
|
||||
description: 'Average response time in milliseconds',
|
||||
@@ -231,64 +210,16 @@ export const dnsAnalyticsTool: ToolConfig<
|
||||
},
|
||||
},
|
||||
min: {
|
||||
type: 'object',
|
||||
description: 'Minimum values across the analytics period',
|
||||
type: 'json',
|
||||
description:
|
||||
'Per-metric minimums. Cloudflare documents this field as currently always an empty object, so treat a populated value as unexpected rather than relied upon.',
|
||||
optional: true,
|
||||
properties: {
|
||||
queryCount: { type: 'number', description: 'Minimum number of DNS queries' },
|
||||
uncachedCount: { type: 'number', description: 'Minimum number of uncached DNS queries' },
|
||||
staleCount: { type: 'number', description: 'Minimum number of stale DNS queries' },
|
||||
responseTimeAvg: {
|
||||
type: 'number',
|
||||
description: 'Minimum average response time in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
responseTimeMedian: {
|
||||
type: 'number',
|
||||
description: 'Minimum median response time in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
responseTime90th: {
|
||||
type: 'number',
|
||||
description: 'Minimum 90th percentile response time in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
responseTime99th: {
|
||||
type: 'number',
|
||||
description: 'Minimum 99th percentile response time in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
max: {
|
||||
type: 'object',
|
||||
description: 'Maximum values across the analytics period',
|
||||
type: 'json',
|
||||
description:
|
||||
'Per-metric maximums. Cloudflare documents this field as currently always an empty object, so treat a populated value as unexpected rather than relied upon.',
|
||||
optional: true,
|
||||
properties: {
|
||||
queryCount: { type: 'number', description: 'Maximum number of DNS queries' },
|
||||
uncachedCount: { type: 'number', description: 'Maximum number of uncached DNS queries' },
|
||||
staleCount: { type: 'number', description: 'Maximum number of stale DNS queries' },
|
||||
responseTimeAvg: {
|
||||
type: 'number',
|
||||
description: 'Maximum average response time in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
responseTimeMedian: {
|
||||
type: 'number',
|
||||
description: 'Maximum median response time in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
responseTime90th: {
|
||||
type: 'number',
|
||||
description: 'Maximum 90th percentile response time in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
responseTime99th: {
|
||||
type: 'number',
|
||||
description: 'Maximum 99th percentile response time in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
type: 'array',
|
||||
|
||||
@@ -132,7 +132,7 @@ export const getZoneTool: ToolConfig<CloudflareGetZoneParams, CloudflareGetZoneR
|
||||
description: 'Zone status (initializing, pending, active, moved)',
|
||||
},
|
||||
paused: { type: 'boolean', description: 'Whether the zone is paused' },
|
||||
type: { type: 'string', description: 'Zone type (full, partial, or secondary)' },
|
||||
type: { type: 'string', description: 'Zone type (full, partial, secondary, or internal)' },
|
||||
name_servers: {
|
||||
type: 'array',
|
||||
description: 'Assigned Cloudflare name servers',
|
||||
|
||||
@@ -1,17 +1,49 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type {
|
||||
CloudflareEnvelope,
|
||||
CloudflareGetZoneSettingsParams,
|
||||
CloudflareGetZoneSettingsResponse,
|
||||
CloudflareRawZoneSetting,
|
||||
} from '@/tools/cloudflare/types'
|
||||
import {
|
||||
cloudflareErrorMessage,
|
||||
cloudflareHeaders,
|
||||
DEFAULT_ZONE_SETTING_IDS,
|
||||
MAX_ZONE_SETTING_IDS,
|
||||
requestedZoneSettingIds,
|
||||
} from '@/tools/cloudflare/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/** Builds the per-setting endpoint Cloudflare directs integrations at. */
|
||||
function zoneSettingUrl(zoneId: string, settingId: string): string {
|
||||
return `https://api.cloudflare.com/client/v4/zones/${zoneId}/settings/${encodeURIComponent(settingId)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens one setting onto the output shape. Cloudflare returns complex values
|
||||
* (minify, security header, NEL) as objects, so those are JSON-stringified to
|
||||
* keep every entry in the list a string.
|
||||
*/
|
||||
function mapZoneSetting(settingId: string, setting: CloudflareRawZoneSetting | undefined) {
|
||||
return {
|
||||
id: setting?.id ?? settingId,
|
||||
value:
|
||||
typeof setting?.value === 'object' && setting.value !== null
|
||||
? JSON.stringify(setting.value)
|
||||
: String(setting?.value ?? ''),
|
||||
editable: setting?.editable ?? false,
|
||||
modified_on: setting?.modified_on ?? '',
|
||||
...(setting?.time_remaining != null ? { time_remaining: setting.time_remaining } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const getZoneSettingsTool: ToolConfig<
|
||||
CloudflareGetZoneSettingsParams,
|
||||
CloudflareGetZoneSettingsResponse
|
||||
> = {
|
||||
id: 'cloudflare_get_zone_settings',
|
||||
name: 'Cloudflare Get Zone Settings',
|
||||
description:
|
||||
'Gets all settings for a zone including SSL mode, caching level, and security settings.',
|
||||
description: `Reads zone settings such as SSL mode, minimum TLS version, security level, and caching level. Cloudflare retired the endpoint that read every setting in one request, so each setting is read individually — name the ones you need to keep the read small. Defaults to ${DEFAULT_ZONE_SETTING_IDS.join(', ')}.`,
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
@@ -21,6 +53,12 @@ export const getZoneSettingsTool: ToolConfig<
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The zone ID to get settings for',
|
||||
},
|
||||
settingIds: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: `Comma-separated setting IDs to read, e.g. "ssl,min_tls_version,security_level". Leave blank to read the default set (${DEFAULT_ZONE_SETTING_IDS.join(', ')}). At most ${MAX_ZONE_SETTING_IDS} settings per call.`,
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
@@ -30,49 +68,82 @@ export const getZoneSettingsTool: ToolConfig<
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/settings`,
|
||||
url: (params) =>
|
||||
zoneSettingUrl(params.zoneId.trim(), requestedZoneSettingIds(params.settingIds)[0]),
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
headers: (params) => cloudflareHeaders(params.apiKey),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
/**
|
||||
* Cloudflare deprecated the batch `GET /zones/{zone_id}/settings` endpoint,
|
||||
* which reaches end of life on 2027-03-31, in favour of one request per
|
||||
* setting. The reads are fanned out and gathered back into the single list
|
||||
* this tool has always returned.
|
||||
*
|
||||
* A setting the zone's plan does not expose answers with an error rather than
|
||||
* a value, so one refusal must not lose the settings that did come back. Those
|
||||
* ids are reported in `unreadable` instead, and only a read where nothing at
|
||||
* all was readable fails.
|
||||
* https://developers.cloudflare.com/fundamentals/api/reference/deprecations/
|
||||
*/
|
||||
directExecution: async (params, signal) => {
|
||||
const settingIds = requestedZoneSettingIds(params.settingIds)
|
||||
if (settingIds.length > MAX_ZONE_SETTING_IDS) {
|
||||
return {
|
||||
success: false,
|
||||
output: { settings: [] },
|
||||
error: data.errors?.[0]?.message ?? 'Failed to get zone settings',
|
||||
output: { settings: [], unreadable: [] },
|
||||
error: `Too many settings requested: ${settingIds.length}. Cloudflare reads one setting per request, so at most ${MAX_ZONE_SETTING_IDS} can be read in a single call.`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
settings:
|
||||
data.result?.map((setting: Record<string, unknown>) => ({
|
||||
id: (setting.id as string) ?? '',
|
||||
value:
|
||||
typeof setting.value === 'object' && setting.value !== null
|
||||
? JSON.stringify(setting.value)
|
||||
: String(setting.value ?? ''),
|
||||
editable: (setting.editable as boolean) ?? false,
|
||||
modified_on: (setting.modified_on as string) ?? '',
|
||||
...(setting.time_remaining != null
|
||||
? { time_remaining: setting.time_remaining as number }
|
||||
: {}),
|
||||
})) ?? [],
|
||||
},
|
||||
const zoneId = params.zoneId.trim()
|
||||
const headers = cloudflareHeaders(params.apiKey)
|
||||
|
||||
const reads = await Promise.all(
|
||||
settingIds.map(async (settingId) => {
|
||||
try {
|
||||
const response = await fetch(zoneSettingUrl(zoneId, settingId), {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal,
|
||||
})
|
||||
const data = (await response.json()) as CloudflareEnvelope<CloudflareRawZoneSetting>
|
||||
if (!data.success) {
|
||||
return {
|
||||
settingId,
|
||||
error: cloudflareErrorMessage(data, `Failed to read zone setting ${settingId}`),
|
||||
}
|
||||
}
|
||||
return { settingId, setting: mapZoneSetting(settingId, data.result) }
|
||||
} catch (error) {
|
||||
return {
|
||||
settingId,
|
||||
error: getErrorMessage(error, `Failed to read zone setting ${settingId}`),
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const settings = reads.flatMap((read) => (read.setting ? [read.setting] : []))
|
||||
const unreadable = reads.flatMap((read) =>
|
||||
read.error ? [{ id: read.settingId, error: read.error }] : []
|
||||
)
|
||||
|
||||
if (settings.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
output: { settings, unreadable },
|
||||
error: unreadable[0]?.error ?? 'Failed to get zone settings',
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, output: { settings, unreadable } }
|
||||
},
|
||||
|
||||
outputs: {
|
||||
settings: {
|
||||
type: 'array',
|
||||
description: 'List of zone settings',
|
||||
description: 'The zone settings that were readable',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -96,11 +167,23 @@ export const getZoneSettingsTool: ToolConfig<
|
||||
time_remaining: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Seconds remaining until the setting can be modified again (only present for rate-limited settings)',
|
||||
'Development mode countdown, in seconds. Cloudflare documents this only on the zones_development_mode setting, where it is the interval from when development mode expires (positive) or last expired (negative)',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
unreadable: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Requested settings Cloudflare refused, typically because the zone plan does not expose them or the setting ID does not exist',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'The requested setting identifier' },
|
||||
error: { type: 'string', description: 'Why Cloudflare would not return the setting' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -87,59 +87,58 @@ export const listCertificatesTool: ToolConfig<
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
certificates:
|
||||
data.result?.map((cert) => ({
|
||||
id: cert.id ?? '',
|
||||
type: cert.type ?? '',
|
||||
hosts: cert.hosts ?? [],
|
||||
primary_certificate: cert.primary_certificate ?? '',
|
||||
status: cert.status ?? '',
|
||||
certificates:
|
||||
cert.certificates?.map((c) => ({
|
||||
id: c.id ?? '',
|
||||
hosts: c.hosts ?? [],
|
||||
issuer: c.issuer ?? '',
|
||||
signature: c.signature ?? '',
|
||||
status: c.status ?? '',
|
||||
bundle_method: c.bundle_method ?? '',
|
||||
zone_id: c.zone_id ?? '',
|
||||
uploaded_on: c.uploaded_on ?? '',
|
||||
modified_on: c.modified_on ?? '',
|
||||
expires_on: c.expires_on ?? '',
|
||||
priority: c.priority ?? 0,
|
||||
geo_restrictions: c.geo_restrictions ?? undefined,
|
||||
})) ?? [],
|
||||
cloudflare_branding: cert.cloudflare_branding ?? false,
|
||||
validation_method: cert.validation_method ?? '',
|
||||
validity_days: cert.validity_days ?? 0,
|
||||
certificate_authority: cert.certificate_authority ?? '',
|
||||
validation_errors:
|
||||
cert.validation_errors?.map((e) => ({
|
||||
message: e.message ?? '',
|
||||
})) ?? [],
|
||||
validation_records:
|
||||
cert.validation_records?.map((r) => ({
|
||||
cname: r.cname ?? '',
|
||||
cname_target: r.cname_target ?? '',
|
||||
emails: r.emails ?? [],
|
||||
http_body: r.http_body ?? '',
|
||||
http_url: r.http_url ?? '',
|
||||
status: r.status ?? '',
|
||||
txt_name: r.txt_name ?? '',
|
||||
txt_value: r.txt_value ?? '',
|
||||
})) ?? [],
|
||||
dcv_delegation_records:
|
||||
cert.dcv_delegation_records?.map((r) => ({
|
||||
cname: r.cname ?? '',
|
||||
cname_target: r.cname_target ?? '',
|
||||
emails: r.emails ?? [],
|
||||
http_body: r.http_body ?? '',
|
||||
http_url: r.http_url ?? '',
|
||||
status: r.status ?? '',
|
||||
txt_name: r.txt_name ?? '',
|
||||
txt_value: r.txt_value ?? '',
|
||||
})) ?? [],
|
||||
})) ?? [],
|
||||
certificates: (Array.isArray(data.result) ? data.result : []).map((cert) => ({
|
||||
id: cert.id ?? '',
|
||||
type: cert.type ?? '',
|
||||
hosts: cert.hosts ?? [],
|
||||
primary_certificate: cert.primary_certificate ?? '',
|
||||
status: cert.status ?? '',
|
||||
certificates:
|
||||
cert.certificates?.map((c) => ({
|
||||
id: c.id ?? '',
|
||||
hosts: c.hosts ?? [],
|
||||
issuer: c.issuer ?? '',
|
||||
signature: c.signature ?? '',
|
||||
status: c.status ?? '',
|
||||
bundle_method: c.bundle_method ?? '',
|
||||
zone_id: c.zone_id ?? '',
|
||||
uploaded_on: c.uploaded_on ?? '',
|
||||
modified_on: c.modified_on ?? '',
|
||||
expires_on: c.expires_on ?? '',
|
||||
priority: c.priority ?? 0,
|
||||
geo_restrictions: c.geo_restrictions ?? undefined,
|
||||
})) ?? [],
|
||||
cloudflare_branding: cert.cloudflare_branding ?? false,
|
||||
validation_method: cert.validation_method ?? '',
|
||||
validity_days: cert.validity_days ?? 0,
|
||||
certificate_authority: cert.certificate_authority ?? '',
|
||||
validation_errors:
|
||||
cert.validation_errors?.map((e) => ({
|
||||
message: e.message ?? '',
|
||||
})) ?? [],
|
||||
validation_records:
|
||||
cert.validation_records?.map((r) => ({
|
||||
cname: r.cname ?? '',
|
||||
cname_target: r.cname_target ?? '',
|
||||
emails: r.emails ?? [],
|
||||
http_body: r.http_body ?? '',
|
||||
http_url: r.http_url ?? '',
|
||||
status: r.status ?? '',
|
||||
txt_name: r.txt_name ?? '',
|
||||
txt_value: r.txt_value ?? '',
|
||||
})) ?? [],
|
||||
dcv_delegation_records:
|
||||
cert.dcv_delegation_records?.map((r) => ({
|
||||
cname: r.cname ?? '',
|
||||
cname_target: r.cname_target ?? '',
|
||||
emails: r.emails ?? [],
|
||||
http_body: r.http_body ?? '',
|
||||
http_url: r.http_url ?? '',
|
||||
status: r.status ?? '',
|
||||
txt_name: r.txt_name ?? '',
|
||||
txt_value: r.txt_value ?? '',
|
||||
})) ?? [],
|
||||
})),
|
||||
total_count: data.result_info?.total_count ?? data.result?.length ?? 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -150,27 +150,26 @@ export const listDnsRecordsTool: ToolConfig<
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
records:
|
||||
data.result?.map((record) => ({
|
||||
id: record.id ?? '',
|
||||
zone_id: record.zone_id ?? '',
|
||||
zone_name: record.zone_name ?? '',
|
||||
type: record.type ?? '',
|
||||
name: record.name ?? '',
|
||||
content: record.content ?? '',
|
||||
proxiable: record.proxiable ?? false,
|
||||
proxied: record.proxied ?? false,
|
||||
ttl: record.ttl ?? 0,
|
||||
locked: record.locked ?? false,
|
||||
priority: record.priority ?? null,
|
||||
comment: record.comment ?? null,
|
||||
tags: record.tags ?? [],
|
||||
comment_modified_on: record.comment_modified_on ?? null,
|
||||
tags_modified_on: record.tags_modified_on ?? null,
|
||||
meta: record.meta ?? null,
|
||||
created_on: record.created_on ?? '',
|
||||
modified_on: record.modified_on ?? '',
|
||||
})) ?? [],
|
||||
records: (Array.isArray(data.result) ? data.result : []).map((record) => ({
|
||||
id: record.id ?? '',
|
||||
zone_id: record.zone_id ?? '',
|
||||
zone_name: record.zone_name ?? '',
|
||||
type: record.type ?? '',
|
||||
name: record.name ?? '',
|
||||
content: record.content ?? '',
|
||||
proxiable: record.proxiable ?? false,
|
||||
proxied: record.proxied ?? false,
|
||||
ttl: record.ttl ?? 0,
|
||||
locked: record.locked ?? false,
|
||||
priority: record.priority ?? null,
|
||||
comment: record.comment ?? null,
|
||||
tags: record.tags ?? [],
|
||||
comment_modified_on: record.comment_modified_on ?? null,
|
||||
tags_modified_on: record.tags_modified_on ?? null,
|
||||
meta: record.meta ?? null,
|
||||
created_on: record.created_on ?? '',
|
||||
modified_on: record.modified_on ?? '',
|
||||
})),
|
||||
total_count: data.result_info?.total_count ?? data.result?.length ?? 0,
|
||||
},
|
||||
}
|
||||
@@ -193,7 +192,11 @@ export const listDnsRecordsTool: ToolConfig<
|
||||
proxied: { type: 'boolean', description: 'Whether Cloudflare proxy is enabled' },
|
||||
ttl: { type: 'number', description: 'TTL in seconds (1 = automatic)' },
|
||||
locked: { type: 'boolean', description: 'Whether the record is locked' },
|
||||
priority: { type: 'number', description: 'MX/SRV record priority', optional: true },
|
||||
priority: {
|
||||
type: 'number',
|
||||
description: 'Record priority, returned for MX and URI records',
|
||||
optional: true,
|
||||
},
|
||||
comment: {
|
||||
type: 'string',
|
||||
description: 'Comment associated with the record',
|
||||
|
||||
@@ -103,49 +103,48 @@ export const listZonesTool: ToolConfig<CloudflareListZonesParams, CloudflareList
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
zones:
|
||||
data.result?.map((zone) => ({
|
||||
id: zone.id ?? '',
|
||||
name: zone.name ?? '',
|
||||
status: zone.status ?? '',
|
||||
paused: zone.paused ?? false,
|
||||
type: zone.type ?? '',
|
||||
name_servers: zone.name_servers ?? [],
|
||||
original_name_servers: zone.original_name_servers ?? [],
|
||||
created_on: zone.created_on ?? '',
|
||||
modified_on: zone.modified_on ?? '',
|
||||
activated_on: zone.activated_on ?? '',
|
||||
development_mode: zone.development_mode ?? 0,
|
||||
plan: {
|
||||
id: zone.plan?.id ?? '',
|
||||
name: zone.plan?.name ?? '',
|
||||
price: zone.plan?.price ?? 0,
|
||||
is_subscribed: zone.plan?.is_subscribed ?? false,
|
||||
frequency: zone.plan?.frequency ?? '',
|
||||
currency: zone.plan?.currency ?? '',
|
||||
legacy_id: zone.plan?.legacy_id ?? '',
|
||||
},
|
||||
account: {
|
||||
id: zone.account?.id ?? '',
|
||||
name: zone.account?.name ?? '',
|
||||
},
|
||||
owner: {
|
||||
id: zone.owner?.id ?? '',
|
||||
name: zone.owner?.name ?? '',
|
||||
type: zone.owner?.type ?? '',
|
||||
},
|
||||
meta: {
|
||||
cdn_only: zone.meta?.cdn_only ?? false,
|
||||
custom_certificate_quota: zone.meta?.custom_certificate_quota ?? 0,
|
||||
dns_only: zone.meta?.dns_only ?? false,
|
||||
foundation_dns: zone.meta?.foundation_dns ?? false,
|
||||
page_rule_quota: zone.meta?.page_rule_quota ?? 0,
|
||||
phishing_detected: zone.meta?.phishing_detected ?? false,
|
||||
step: zone.meta?.step ?? 0,
|
||||
},
|
||||
vanity_name_servers: zone.vanity_name_servers ?? [],
|
||||
permissions: zone.permissions ?? [],
|
||||
})) ?? [],
|
||||
zones: (Array.isArray(data.result) ? data.result : []).map((zone) => ({
|
||||
id: zone.id ?? '',
|
||||
name: zone.name ?? '',
|
||||
status: zone.status ?? '',
|
||||
paused: zone.paused ?? false,
|
||||
type: zone.type ?? '',
|
||||
name_servers: zone.name_servers ?? [],
|
||||
original_name_servers: zone.original_name_servers ?? [],
|
||||
created_on: zone.created_on ?? '',
|
||||
modified_on: zone.modified_on ?? '',
|
||||
activated_on: zone.activated_on ?? '',
|
||||
development_mode: zone.development_mode ?? 0,
|
||||
plan: {
|
||||
id: zone.plan?.id ?? '',
|
||||
name: zone.plan?.name ?? '',
|
||||
price: zone.plan?.price ?? 0,
|
||||
is_subscribed: zone.plan?.is_subscribed ?? false,
|
||||
frequency: zone.plan?.frequency ?? '',
|
||||
currency: zone.plan?.currency ?? '',
|
||||
legacy_id: zone.plan?.legacy_id ?? '',
|
||||
},
|
||||
account: {
|
||||
id: zone.account?.id ?? '',
|
||||
name: zone.account?.name ?? '',
|
||||
},
|
||||
owner: {
|
||||
id: zone.owner?.id ?? '',
|
||||
name: zone.owner?.name ?? '',
|
||||
type: zone.owner?.type ?? '',
|
||||
},
|
||||
meta: {
|
||||
cdn_only: zone.meta?.cdn_only ?? false,
|
||||
custom_certificate_quota: zone.meta?.custom_certificate_quota ?? 0,
|
||||
dns_only: zone.meta?.dns_only ?? false,
|
||||
foundation_dns: zone.meta?.foundation_dns ?? false,
|
||||
page_rule_quota: zone.meta?.page_rule_quota ?? 0,
|
||||
phishing_detected: zone.meta?.phishing_detected ?? false,
|
||||
step: zone.meta?.step ?? 0,
|
||||
},
|
||||
vanity_name_servers: zone.vanity_name_servers ?? [],
|
||||
permissions: zone.permissions ?? [],
|
||||
})),
|
||||
total_count: data.result_info?.total_count ?? data.result?.length ?? 0,
|
||||
},
|
||||
}
|
||||
@@ -165,7 +164,10 @@ export const listZonesTool: ToolConfig<CloudflareListZonesParams, CloudflareList
|
||||
description: 'Zone status (initializing, pending, active, moved)',
|
||||
},
|
||||
paused: { type: 'boolean', description: 'Whether the zone is paused' },
|
||||
type: { type: 'string', description: 'Zone type (full, partial, or secondary)' },
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'Zone type (full, partial, secondary, or internal)',
|
||||
},
|
||||
name_servers: {
|
||||
type: 'array',
|
||||
description: 'Assigned Cloudflare name servers',
|
||||
|
||||
@@ -67,10 +67,6 @@ export const purgeCacheTool: ToolConfig<CloudflarePurgeCacheParams, CloudflarePu
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
if (params.purge_everything) {
|
||||
return { purge_everything: true }
|
||||
}
|
||||
|
||||
const body: Record<string, string[]> = {}
|
||||
if (params.files) {
|
||||
const fileList = String(params.files)
|
||||
@@ -101,20 +97,36 @@ export const purgeCacheTool: ToolConfig<CloudflarePurgeCacheParams, CloudflarePu
|
||||
if (prefixList.length > 0) body.prefixes = prefixList
|
||||
}
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
const targets = Object.keys(body)
|
||||
|
||||
/**
|
||||
* `purge_everything` wipes the zone's entire cache, so a request that
|
||||
* also names specific targets is ambiguous about what the caller wanted.
|
||||
* Erroring is safer than silently discarding the target list and purging
|
||||
* everything anyway.
|
||||
*/
|
||||
if (params.purge_everything) {
|
||||
if (targets.length > 0) {
|
||||
throw new Error(
|
||||
`purge_everything purges the entire zone cache and cannot be combined with specific targets, but ${targets.join(' and ')} were also provided. Set purge_everything to false to purge only those targets.`
|
||||
)
|
||||
}
|
||||
return { purge_everything: true }
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
throw new Error(
|
||||
'No purge targets specified. Provide at least one of: files, tags, hosts, or prefixes, or set purge_everything to true.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare's purge body is a one-of over the five target kinds — each
|
||||
* is its own request schema, and combining two in a single call is not a
|
||||
* documented shape. Rejecting here names the conflicting fields instead
|
||||
* of letting the API answer with a generic parse error.
|
||||
* Cloudflare's purge request body is an `anyOf` over the target kinds and
|
||||
* no Cloudflare page documents whether mixing them is supported. Sim
|
||||
* refuses the combination conservatively so the caller gets a named error
|
||||
* instead of a generic 400.
|
||||
* https://developers.cloudflare.com/api/resources/cache/methods/purge/
|
||||
*/
|
||||
const targets = Object.keys(body)
|
||||
if (targets.length > 1) {
|
||||
throw new Error(
|
||||
`Only one purge target kind is allowed per request, but ${targets.join(' and ')} were provided. Run a separate purge for each.`
|
||||
|
||||
@@ -309,8 +309,8 @@ export interface CloudflareRawDnsAnalyticsAggregate {
|
||||
/** Raw DNS analytics report payload. */
|
||||
export interface CloudflareRawDnsAnalyticsReport {
|
||||
totals?: CloudflareRawDnsAnalyticsAggregate
|
||||
min?: CloudflareRawDnsAnalyticsAggregate
|
||||
max?: CloudflareRawDnsAnalyticsAggregate
|
||||
min?: Record<string, unknown>
|
||||
max?: Record<string, unknown>
|
||||
data?: Array<{ dimensions?: string[]; metrics?: number[] }>
|
||||
data_lag?: number
|
||||
rows?: number
|
||||
@@ -683,17 +683,22 @@ export interface CloudflareDnsAnalyticsParams extends CloudflareBaseParams {
|
||||
zoneId: string
|
||||
since?: string
|
||||
until?: string
|
||||
metrics: string
|
||||
metrics?: string
|
||||
dimensions?: string
|
||||
filters?: string
|
||||
sort?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate metric block. Cloudflare only populates the metrics that were
|
||||
* requested, so every field is optional — an absent field means "not requested"
|
||||
* rather than zero.
|
||||
*/
|
||||
interface CloudflareDnsAnalyticsTotals {
|
||||
queryCount: number
|
||||
uncachedCount: number
|
||||
staleCount: number
|
||||
queryCount?: number
|
||||
uncachedCount?: number
|
||||
staleCount?: number
|
||||
responseTimeAvg?: number
|
||||
responseTimeMedian?: number
|
||||
responseTime90th?: number
|
||||
@@ -713,8 +718,10 @@ interface CloudflareDnsAnalyticsQuery {
|
||||
export interface CloudflareDnsAnalyticsResponse extends ToolResponse {
|
||||
output: {
|
||||
totals: CloudflareDnsAnalyticsTotals
|
||||
min: CloudflareDnsAnalyticsTotals
|
||||
max: CloudflareDnsAnalyticsTotals
|
||||
/** Cloudflare documents this as "currently always an empty object". */
|
||||
min: Record<string, unknown> | null
|
||||
/** Cloudflare documents this as "currently always an empty object". */
|
||||
max: Record<string, unknown> | null
|
||||
data: Array<{
|
||||
dimensions: string[]
|
||||
metrics: number[]
|
||||
@@ -727,6 +734,7 @@ export interface CloudflareDnsAnalyticsResponse extends ToolResponse {
|
||||
|
||||
export interface CloudflareGetZoneSettingsParams extends CloudflareBaseParams {
|
||||
zoneId: string
|
||||
settingIds?: string
|
||||
}
|
||||
|
||||
interface CloudflareZoneSetting {
|
||||
@@ -737,9 +745,25 @@ interface CloudflareZoneSetting {
|
||||
time_remaining?: number
|
||||
}
|
||||
|
||||
/** Raw zone setting payload, as returned by the per-setting endpoint. */
|
||||
export interface CloudflareRawZoneSetting {
|
||||
id?: string
|
||||
value?: unknown
|
||||
editable?: boolean
|
||||
modified_on?: string | null
|
||||
time_remaining?: number | null
|
||||
}
|
||||
|
||||
/** A setting Cloudflare refused, so the caller sees the gap rather than a silent omission. */
|
||||
interface CloudflareUnreadableZoneSetting {
|
||||
id: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface CloudflareGetZoneSettingsResponse extends ToolResponse {
|
||||
output: {
|
||||
settings: CloudflareZoneSetting[]
|
||||
unreadable: CloudflareUnreadableZoneSetting[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -909,6 +933,9 @@ export interface CloudflareUpdateRateLimitRuleParams extends CloudflareBaseParam
|
||||
requestsToOrigin?: boolean
|
||||
description?: string
|
||||
enabled?: boolean
|
||||
ref?: string
|
||||
actionParameters?: string
|
||||
logging?: string
|
||||
}
|
||||
|
||||
interface CloudflareAccessApplication {
|
||||
|
||||
@@ -20,7 +20,7 @@ export const updateAccessApplicationTool: ToolConfig<
|
||||
id: 'cloudflare_update_access_application',
|
||||
name: 'Cloudflare Update Access Application',
|
||||
description:
|
||||
'Updates a Cloudflare Access (Zero Trust) application. This replaces the application definition rather than merging it, so send every field the application should keep — anything you omit reverts to its default, which can widen or break access. Read the current configuration with "Get Access Application" first. Requires an API token with Account Access: Apps and Policies Edit.',
|
||||
'Updates a Cloudflare Access (Zero Trust) application. Cloudflare does not document merge behavior for this PUT, so treat it as a replace: send every field the application should keep, because an omitted field may revert to its default and widen or break access. Read the current configuration with "Get Access Application" first. Requires an API token with Account Access: Apps and Policies Edit.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
|
||||
@@ -18,7 +18,7 @@ export const updateAccessPolicyTool: ToolConfig<
|
||||
id: 'cloudflare_update_access_policy',
|
||||
name: 'Cloudflare Update Access Policy',
|
||||
description:
|
||||
'Updates a Cloudflare Access (Zero Trust) policy on an application. This replaces the policy definition rather than merging it, so send every rule the policy should keep — omitted exclude or require rules are dropped, which can widen who gets in. The change applies to live traffic immediately. Read the current policy with "List Access Policies" first. Requires an API token with Account Access: Apps and Policies Edit.',
|
||||
'Updates a Cloudflare Access (Zero Trust) policy on an application. Cloudflare does not document merge behavior for this PUT, so treat it as a replace: send every rule the policy should keep, because an omitted exclude or require rule may be dropped and widen who gets in. The change applies to live traffic immediately. Read the current policy with "List Access Policies" first. Requires an API token with Account Access: Apps and Policies Edit.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
|
||||
@@ -60,7 +60,8 @@ export const updateDnsRecordTool: ToolConfig<
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Priority for MX and SRV records',
|
||||
description:
|
||||
'Record priority. Cloudflare accepts this top-level field for MX and URI records only; an SRV record carries its priority, weight, port, and target inside the record content instead',
|
||||
},
|
||||
comment: {
|
||||
type: 'string',
|
||||
@@ -190,7 +191,11 @@ export const updateDnsRecordTool: ToolConfig<
|
||||
proxied: { type: 'boolean', description: 'Whether Cloudflare proxy is enabled' },
|
||||
ttl: { type: 'number', description: 'Time to live in seconds (1 = automatic)' },
|
||||
locked: { type: 'boolean', description: 'Whether the record is locked' },
|
||||
priority: { type: 'number', description: 'Priority for MX and SRV records', optional: true },
|
||||
priority: {
|
||||
type: 'number',
|
||||
description: 'Record priority, returned for MX and URI records',
|
||||
optional: true,
|
||||
},
|
||||
comment: { type: 'string', description: 'Comment associated with the record', optional: true },
|
||||
tags: {
|
||||
type: 'array',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
emptyRuleset,
|
||||
mapRuleset,
|
||||
parseCsvParam,
|
||||
parseJsonObjectParam,
|
||||
} from '@/tools/cloudflare/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
@@ -52,7 +53,7 @@ export const updateRateLimitRuleTool: ToolConfig<
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Comma-separated counting characteristics. cf.colo.id is mandatory, plus exactly one of ip.src or cf.unique_visitor_id',
|
||||
'Comma-separated counting characteristics. cf.colo.id is mandatory. ip.src and cf.unique_visitor_id are mutually exclusive — include at most one.',
|
||||
},
|
||||
period: {
|
||||
type: 'number',
|
||||
@@ -105,6 +106,27 @@ export const updateRateLimitRuleTool: ToolConfig<
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether the rule is enabled',
|
||||
},
|
||||
ref: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Reference tag that stays stable across rule updates. Because the update replaces the rule, omitting it resets the tag to the rule ID and breaks anything matching on the old value',
|
||||
},
|
||||
actionParameters: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'JSON object of action-specific parameters for the mitigation action, e.g. {"response":{"status_code":429,"content":"{\\"error\\":\\"rate limited\\"}","content_type":"application/json"}} for a custom block response. Because the update replaces the rule, omitting it resets action_parameters to {} and the rule falls back to Cloudflare\'s default block page',
|
||||
},
|
||||
logging: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'JSON logging configuration to preserve, e.g. {"enabled":true}. Omitting it on a rule that had logging configured resets it to the default',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
@@ -144,6 +166,20 @@ export const updateRateLimitRuleTool: ToolConfig<
|
||||
}
|
||||
if (params.description !== undefined) body.description = params.description
|
||||
if (params.enabled !== undefined) body.enabled = params.enabled
|
||||
if (params.ref) body.ref = params.ref
|
||||
|
||||
/**
|
||||
* PATCH replaces the whole rule definition, so any of these left out falls
|
||||
* back to its schema default: a custom block response reverts to
|
||||
* Cloudflare's default block page and the logging configuration resets.
|
||||
* Forward them whenever the caller resends them.
|
||||
* https://developers.cloudflare.com/ruleset-engine/rulesets-api/update-rule/
|
||||
*/
|
||||
const actionParameters = parseJsonObjectParam(params.actionParameters, 'Action Parameters')
|
||||
if (actionParameters) body.action_parameters = actionParameters
|
||||
|
||||
const logging = parseJsonObjectParam(params.logging, 'Logging Configuration')
|
||||
if (logging) body.logging = logging
|
||||
|
||||
return body
|
||||
},
|
||||
|
||||
@@ -136,7 +136,7 @@ export const updateZoneSettingTool: ToolConfig<
|
||||
time_remaining: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Seconds remaining until the setting can be modified again (only present for rate-limited settings)',
|
||||
'Development mode countdown, in seconds. Cloudflare documents this only on the zones_development_mode setting, where it is the interval from when development mode expires (positive) or last expired (negative)',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -216,6 +216,42 @@ export function emptyAccessPolicy() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone settings "Get Zone Settings" reads when the caller names none.
|
||||
*
|
||||
* Cloudflare deprecated the batch `GET /zones/{zone_id}/settings` endpoint,
|
||||
* which reaches end of life on 2027-03-31, and directs integrations at the
|
||||
* per-setting `GET /zones/{zone_id}/settings/{setting_id}` endpoint. "Read the
|
||||
* zone's settings" is therefore a fan-out over an explicit, bounded list rather
|
||||
* than one request.
|
||||
* https://developers.cloudflare.com/fundamentals/api/reference/deprecations/
|
||||
*/
|
||||
export const DEFAULT_ZONE_SETTING_IDS = [
|
||||
'ssl',
|
||||
'always_use_https',
|
||||
'min_tls_version',
|
||||
'tls_1_3',
|
||||
'security_level',
|
||||
'cache_level',
|
||||
'browser_cache_ttl',
|
||||
'development_mode',
|
||||
'rocket_loader',
|
||||
'email_obfuscation',
|
||||
'hotlink_protection',
|
||||
'ip_geolocation',
|
||||
'http2',
|
||||
'http3',
|
||||
'websockets',
|
||||
]
|
||||
|
||||
/** Upper bound on one zone-settings fan-out, so a pasted list cannot become an unbounded burst. */
|
||||
export const MAX_ZONE_SETTING_IDS = 40
|
||||
|
||||
/** Resolves the requested zone setting ids, falling back to the default read set. */
|
||||
export function requestedZoneSettingIds(settingIds: unknown): string[] {
|
||||
return parseCsvParam(settingIds) ?? DEFAULT_ZONE_SETTING_IDS
|
||||
}
|
||||
|
||||
/** Appends a query param when the value is present and non-empty. */
|
||||
export function appendParam(url: URL, key: string, value: unknown): void {
|
||||
if (value === undefined || value === null || value === '') return
|
||||
|
||||
@@ -38,7 +38,7 @@ export const crowdstrikeCreateIndicatorsTool: ToolConfig<
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'JSON array of indicators to create. Each entry requires type, value, and applied_globally (boolean). type is one of sha256, md5, domain, ipv4, ipv6; action is one of no_action, allow, prevent_no_ui, prevent, detect; severity is one of informational, low, medium, high, critical; platforms entries are windows, mac, or linux. Other documented fields: host_groups (array), description, source, tags (array), expiration (ISO 8601), mobile_action, metadata ({ filename }). Either applied_globally must be true or host_groups must be supplied. Tenants can extend these value sets, so treat them as the documented defaults rather than a closed list.',
|
||||
'JSON array of indicators to create. Each entry requires type, value, and applied_globally (boolean). type is one of sha256, md5, domain, ipv4, ipv6; action is one of no_action, allow, prevent, detect (prevent_no_ui is widely reported and appears in the Falcon console, but CrowdStrike does not enumerate it in the IOC API docs - call GET /iocs/queries/actions/v1 to read the actions your tenant actually accepts); severity is one of informational, low, medium, high, critical; platforms entries are windows, mac, or linux. Other documented fields: host_groups (array), description, source, tags (array), expiration (ISO 8601), mobile_action, metadata ({ filename }). Either applied_globally must be true or host_groups must be supplied. Tenants can extend these value sets, so treat them as the documented defaults rather than a closed list.',
|
||||
},
|
||||
comment: {
|
||||
type: 'string',
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { crowdstrikeQueryBodySchema } from '@/lib/api/contracts/tools/crowdstrike'
|
||||
import { CrowdStrikeBlock } from '@/blocks/blocks/crowdstrike'
|
||||
import { crowdstrikeCreateIndicatorsTool } from '@/tools/crowdstrike/create_indicators'
|
||||
import { crowdstrikeExecuteRtrCommandTool } from '@/tools/crowdstrike/execute_rtr_command'
|
||||
import { crowdstrikeGetSensorAggregatesTool } from '@/tools/crowdstrike/get_sensor_aggregates'
|
||||
import { crowdstrikeGetSensorDetailsTool } from '@/tools/crowdstrike/get_sensor_details'
|
||||
import { crowdstrikeQueryAlertsTool } from '@/tools/crowdstrike/query_alerts'
|
||||
import { crowdstrikeQueryIndicatorsTool } from '@/tools/crowdstrike/query_indicators'
|
||||
import { crowdstrikeQuerySensorsTool } from '@/tools/crowdstrike/query_sensors'
|
||||
import type {
|
||||
CrowdStrikeGetSensorAggregatesResponse,
|
||||
@@ -168,27 +170,81 @@ describe('CrowdStrike RTR read-only base commands', () => {
|
||||
})
|
||||
|
||||
describe('CrowdStrike sort placeholders', () => {
|
||||
const sortBlocks = CrowdStrikeBlock.subBlocks.filter((block) => block.id === 'sort')
|
||||
|
||||
function placeholderFor(operation: string) {
|
||||
const match = sortBlocks.find((block) => {
|
||||
const declared = (block.condition as { value: string | string[] }).value
|
||||
return Array.isArray(declared) ? declared.includes(operation) : declared === operation
|
||||
})
|
||||
return match?.placeholder
|
||||
}
|
||||
|
||||
it('shows the dot form for the collections that document it and the pipe form elsewhere', () => {
|
||||
const sortBlocks = CrowdStrikeBlock.subBlocks.filter((block) => block.id === 'sort')
|
||||
expect(sortBlocks).toHaveLength(2)
|
||||
expect(sortBlocks).toHaveLength(3)
|
||||
|
||||
for (const operation of ['crowdstrike_query_host_groups', 'crowdstrike_query_sensors']) {
|
||||
const match = sortBlocks.find((block) =>
|
||||
(block.condition as { value: string[] }).value.includes(operation)
|
||||
)
|
||||
expect(match?.placeholder).toBe('name.asc')
|
||||
expect(placeholderFor(operation)).toBe('name.asc')
|
||||
}
|
||||
|
||||
for (const operation of [
|
||||
'crowdstrike_query_alerts',
|
||||
'crowdstrike_query_indicators',
|
||||
'crowdstrike_query_vulnerabilities',
|
||||
'crowdstrike_query_cases',
|
||||
]) {
|
||||
const match = sortBlocks.find((block) =>
|
||||
(block.condition as { value: string[] }).value.includes(operation)
|
||||
)
|
||||
expect(match?.placeholder).toBe('created_timestamp|desc')
|
||||
expect(placeholderFor(operation)).toBe('created_timestamp|desc')
|
||||
}
|
||||
})
|
||||
|
||||
it('steers IOC Management to the dot form and away from created_timestamp', () => {
|
||||
// PSFalcon's Get-FalconIoc -Sort ValidateSet is the dot form, and the IOC
|
||||
// sort enum has no created_timestamp - the timestamp fields are created_on
|
||||
// and modified_on. The pipe form here sent users toward a sort Falcon rejects.
|
||||
const placeholder = placeholderFor('crowdstrike_query_indicators')
|
||||
expect(placeholder).toBe('created_on.desc')
|
||||
expect(placeholder).not.toContain('|')
|
||||
expect(placeholder).not.toContain('created_timestamp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CrowdStrike limit caps name their real source', () => {
|
||||
it('does not attribute the IOC indicator cap to CrowdStrike', () => {
|
||||
// GET /iocs/queries/indicators/v1 publishes no `maximum`, unlike host groups
|
||||
// (5000) and Spotlight (400), and PSFalcon clamps to 2000 on its own.
|
||||
const messages = issueMessages(
|
||||
crowdstrikeQueryBodySchema.safeParse({
|
||||
...credentials,
|
||||
operation: 'crowdstrike_query_indicators',
|
||||
limit: 501,
|
||||
})
|
||||
)
|
||||
|
||||
expect(
|
||||
messages.some((message) => /Sim caps this request at 500 indicators/.test(message))
|
||||
).toBe(true)
|
||||
expect(messages.some((message) => /CrowdStrike accepts at most 500/.test(message))).toBe(false)
|
||||
expect(crowdstrikeQueryIndicatorsTool.params.limit.description).toMatch(
|
||||
/Sim caps it at 500|publishes no maximum/i
|
||||
)
|
||||
})
|
||||
|
||||
it('still accepts a documented cap at its published maximum', () => {
|
||||
expect(
|
||||
crowdstrikeQueryBodySchema.safeParse({
|
||||
...credentials,
|
||||
operation: 'crowdstrike_query_vulnerabilities',
|
||||
filter: "status:'open'",
|
||||
limit: 400,
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CrowdStrike IOC action list stays inside what CrowdStrike documents', () => {
|
||||
it('does not present prevent_no_ui as a documented action value', () => {
|
||||
const description = crowdstrikeCreateIndicatorsTool.params.indicators.description ?? ''
|
||||
expect(description).toMatch(/no_action, allow, prevent, detect/)
|
||||
expect(description).not.toMatch(/allow, prevent_no_ui, prevent/)
|
||||
expect(description).toMatch(/\/iocs\/queries\/actions\/v1/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,7 +43,8 @@ export const crowdstrikeQueryIndicatorsTool: ToolConfig<
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of IOC IDs to return (1-500, default 100)',
|
||||
description:
|
||||
'Maximum number of IOC IDs to return (default 100). CrowdStrike publishes no maximum for this endpoint; Sim caps it at 500 to keep a single request bounded',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
splitCommaList,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
@@ -64,7 +65,7 @@ export const addIncidentTodoTool: ToolConfig<AddIncidentTodoParams, AddIncidentT
|
||||
url: (params) =>
|
||||
datadogApiUrl(
|
||||
params.site,
|
||||
`/api/v2/incidents/${encodeURIComponent(params.incidentId)}/relationships/todos`
|
||||
`/api/v2/incidents/${datadogPathSegment(params.incidentId)}/relationships/todos`
|
||||
),
|
||||
method: 'POST',
|
||||
headers: datadogHeaders,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CancelDowntimeParams, CancelDowntimeResponse } from '@/tools/datadog/types'
|
||||
import { datadogErrorMessage } from '@/tools/datadog/utils'
|
||||
import { datadogErrorMessage, datadogPathSegment } from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const cancelDowntimeTool: ToolConfig<CancelDowntimeParams, CancelDowntimeResponse> = {
|
||||
@@ -38,7 +38,7 @@ export const cancelDowntimeTool: ToolConfig<CancelDowntimeParams, CancelDowntime
|
||||
request: {
|
||||
url: (params) => {
|
||||
const site = params.site || 'datadoghq.com'
|
||||
const downtimeId = encodeURIComponent(String(params.downtimeId).trim())
|
||||
const downtimeId = datadogPathSegment(params.downtimeId)
|
||||
return `https://api.${site}/api/v2/downtime/${downtimeId}`
|
||||
},
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -70,7 +70,7 @@ export const createEventTool: ToolConfig<CreateEventParams, CreateEventResponse>
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Unix timestamp in seconds when the event occurred (e.g., 1705320000, defaults to now)',
|
||||
'Unix timestamp in seconds when the event occurred (e.g., 1705320000, defaults to now). Datadog limits this to events no older than 18 hours.',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
|
||||
@@ -348,37 +348,51 @@ describe('pagination wiring', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* `MuteMonitor` and `UnmuteMonitor` declare no `requestBody` in the authoritative
|
||||
* spec (`docs.datadoghq.com/resources/json/full_spec_v1.json`); `scope`, `end`,
|
||||
* and `all_scopes` are all `in: query`. Sent as a JSON body they are dropped, and
|
||||
* a scoped, time-boxed mute silently becomes an indefinite mute across every
|
||||
* scope — answered with a 200 and the full monitor object, so nothing surfaces.
|
||||
*
|
||||
* Note the generated `datadog-api-client-go` v1 schema omits these operations
|
||||
* entirely; it is a subset, not the authority.
|
||||
*/
|
||||
describe('monitor mute and unmute', () => {
|
||||
it('mutes with the scope and end datadogpy documents', () => {
|
||||
const body = callBody(muteMonitorTool, {
|
||||
it('mutes with scope and end in the query string, not a body', () => {
|
||||
const url = callUrl(muteMonitorTool, {
|
||||
...auth,
|
||||
monitorId: '123',
|
||||
scope: 'host:web-1',
|
||||
end: 1705323600,
|
||||
} as any)
|
||||
expect(body).toEqual({ scope: 'host:web-1', end: 1705323600 })
|
||||
expect(callUrl(muteMonitorTool, { ...auth, monitorId: '123' } as any)).toContain(
|
||||
'/api/v1/monitor/123/mute'
|
||||
)
|
||||
expect(url).toContain('/api/v1/monitor/123/mute?')
|
||||
expect(url).toContain('scope=host%3Aweb-1')
|
||||
expect(url).toContain('end=1705323600')
|
||||
expect(muteMonitorTool.request.body).toBeUndefined()
|
||||
})
|
||||
|
||||
/** An indefinite mute sends no `end`, so the monitor stays muted until unmuted. */
|
||||
it('omits end when the caller wants an indefinite mute', () => {
|
||||
const body = callBody(muteMonitorTool, { ...auth, monitorId: '123' } as any)
|
||||
expect(body).not.toHaveProperty('end')
|
||||
/** An indefinite, unscoped mute sends neither parameter and no stray `?`. */
|
||||
it('omits end and scope when the caller wants an indefinite mute', () => {
|
||||
const url = callUrl(muteMonitorTool, { ...auth, monitorId: '123' } as any)
|
||||
expect(url).toBe('https://api.datadoghq.com/api/v1/monitor/123/mute')
|
||||
})
|
||||
|
||||
/** Muting is only safe to ship because it can be reversed from Sim. */
|
||||
it('ships an unmute counterpart that can clear every scope', () => {
|
||||
const body = callBody(unmuteMonitorTool, {
|
||||
it('ships an unmute counterpart that can clear every scope, also via query', () => {
|
||||
const url = callUrl(unmuteMonitorTool, {
|
||||
...auth,
|
||||
monitorId: '123',
|
||||
allScopes: true,
|
||||
} as any)
|
||||
expect(body).toEqual({ all_scopes: true })
|
||||
expect(callUrl(unmuteMonitorTool, { ...auth, monitorId: '123' } as any)).toContain(
|
||||
'/api/v1/monitor/123/unmute'
|
||||
)
|
||||
expect(url).toContain('/api/v1/monitor/123/unmute?')
|
||||
expect(url).toContain('all_scopes=true')
|
||||
expect(unmuteMonitorTool.request.body).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits all_scopes and scope when the caller sets neither', () => {
|
||||
const url = callUrl(unmuteMonitorTool, { ...auth, monitorId: '123' } as any)
|
||||
expect(url).toBe('https://api.datadoghq.com/api/v1/monitor/123/unmute')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -482,14 +496,42 @@ describe('list_downtimes limit description', () => {
|
||||
|
||||
describe('list_monitors pagination', () => {
|
||||
/**
|
||||
* Datadog returns every monitor when `page` is absent, so both page params have to reach
|
||||
* the request for the page size to have any effect.
|
||||
* Datadog: `page_size` — "If the page argument is not specified, the default
|
||||
* behavior returns all monitors without a `page_size` limit." So a page size on
|
||||
* its own is inert, and a user who set one from a control that reads as a bound
|
||||
* would get every monitor in the org buffered whole.
|
||||
*/
|
||||
it('sends page and page_size', () => {
|
||||
const url = callUrl(listMonitorsTool, { ...auth, page: 2, pageSize: 50 } as any)
|
||||
expect(url).toContain('page=2')
|
||||
expect(url).toContain('page_size=50')
|
||||
})
|
||||
|
||||
it('implies page 0 when only a page size is set, so the bound actually applies', () => {
|
||||
const url = callUrl(listMonitorsTool, { ...auth, pageSize: 50 } as any)
|
||||
expect(url).toContain('page=0')
|
||||
expect(url).toContain('page_size=50')
|
||||
})
|
||||
|
||||
/** An explicit page 0 is Datadog's first page, not an omission. */
|
||||
it('keeps an explicit page 0', () => {
|
||||
expect(callUrl(listMonitorsTool, { ...auth, page: 0 } as any)).toContain('page=0')
|
||||
})
|
||||
|
||||
/**
|
||||
* Neither set stays unpaginated: defaulting `page` unconditionally would
|
||||
* silently truncate a caller relying on the documented return-everything
|
||||
* behavior, which is the same class of bug as a house `max_count` default.
|
||||
*/
|
||||
it('sends no pagination when the caller sets neither', () => {
|
||||
const url = callUrl(listMonitorsTool, { ...auth } as any)
|
||||
expect(url).not.toContain('page')
|
||||
})
|
||||
|
||||
it('states the page-dependency rule in both parameter descriptions', () => {
|
||||
expect(listMonitorsTool.params.page.description).toMatch(/without pagination/)
|
||||
expect(listMonitorsTool.params.pageSize.description).toMatch(/only applies this when a page/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('list_dashboards filters', () => {
|
||||
@@ -531,3 +573,34 @@ describe('registry surface', () => {
|
||||
expect(createEventTool.params.applicationKey).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('undisclosed vendor limits and Sim defaults', () => {
|
||||
/**
|
||||
* `EventCreateRequest.date_happened` is documented "Limited to events no older
|
||||
* than 18 hours". A backfill outside that window is rejected, or accepted and
|
||||
* clamped, for a reason nothing in the tool explained.
|
||||
*/
|
||||
it('discloses the 18-hour ceiling on create_event date_happened', () => {
|
||||
expect(createEventTool.params.dateHappened.description).toMatch(/18 hours/)
|
||||
})
|
||||
|
||||
/**
|
||||
* `ddsource: 'custom'` is injected by Sim, not by Datadog — and it decides
|
||||
* which log pipeline Datadog applies, so it must not read as a vendor default.
|
||||
*/
|
||||
it('discloses that ddsource="custom" is a Sim default', () => {
|
||||
const description = String(sendLogsTool.params.logs.description)
|
||||
|
||||
expect(description).toMatch(/ddsource="custom"/)
|
||||
expect(description).toMatch(/Sim default, not a Datadog one/)
|
||||
})
|
||||
|
||||
it('still applies that default so an entry without ddsource is not sent bare', () => {
|
||||
const body = callBody(sendLogsTool, {
|
||||
...auth,
|
||||
logs: JSON.stringify([{ message: 'hello' }]),
|
||||
} as any)
|
||||
|
||||
expect(body[0].ddsource).toBe('custom')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { DeleteDashboardParams, DeleteDashboardResponse } from '@/tools/datadog/types'
|
||||
import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils'
|
||||
import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const deleteDashboardTool: ToolConfig<DeleteDashboardParams, DeleteDashboardResponse> = {
|
||||
@@ -37,7 +42,7 @@ export const deleteDashboardTool: ToolConfig<DeleteDashboardParams, DeleteDashbo
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
datadogApiUrl(params.site, `/api/v1/dashboard/${encodeURIComponent(params.dashboardId)}`),
|
||||
datadogApiUrl(params.site, `/api/v1/dashboard/${datadogPathSegment(params.dashboardId)}`),
|
||||
method: 'DELETE',
|
||||
headers: datadogHeaders,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { DeleteSloParams, DeleteSloResponse } from '@/tools/datadog/types'
|
||||
import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils'
|
||||
import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const deleteSloTool: ToolConfig<DeleteSloParams, DeleteSloResponse> = {
|
||||
@@ -47,7 +52,7 @@ export const deleteSloTool: ToolConfig<DeleteSloParams, DeleteSloResponse> = {
|
||||
const queryString = params.force ? '?force=true' : ''
|
||||
return datadogApiUrl(
|
||||
params.site,
|
||||
`/api/v1/slo/${encodeURIComponent(params.sloId)}${queryString}`
|
||||
`/api/v1/slo/${datadogPathSegment(params.sloId)}${queryString}`
|
||||
)
|
||||
},
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
splitCommaList,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
@@ -76,7 +77,7 @@ export const getBrowserSyntheticsResultsTool: ToolConfig<
|
||||
const queryString = queryParams.toString()
|
||||
return datadogApiUrl(
|
||||
params.site,
|
||||
`/api/v1/synthetics/tests/browser/${encodeURIComponent(params.publicId)}/results${
|
||||
`/api/v1/synthetics/tests/browser/${datadogPathSegment(params.publicId)}/results${
|
||||
queryString ? `?${queryString}` : ''
|
||||
}`
|
||||
)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { GetDashboardParams, GetDashboardResponse } from '@/tools/datadog/types'
|
||||
import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils'
|
||||
import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getDashboardTool: ToolConfig<GetDashboardParams, GetDashboardResponse> = {
|
||||
@@ -37,7 +42,7 @@ export const getDashboardTool: ToolConfig<GetDashboardParams, GetDashboardRespon
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
datadogApiUrl(params.site, `/api/v1/dashboard/${encodeURIComponent(params.dashboardId)}`),
|
||||
datadogApiUrl(params.site, `/api/v1/dashboard/${datadogPathSegment(params.dashboardId)}`),
|
||||
method: 'GET',
|
||||
headers: datadogHeaders,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
splitCommaList,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
@@ -53,7 +54,7 @@ export const getIncidentTool: ToolConfig<GetIncidentParams, GetIncidentResponse>
|
||||
const queryString = include ? `?${new URLSearchParams({ include }).toString()}` : ''
|
||||
return datadogApiUrl(
|
||||
params.site,
|
||||
`/api/v2/incidents/${encodeURIComponent(params.incidentId)}${queryString}`
|
||||
`/api/v2/incidents/${datadogPathSegment(params.incidentId)}${queryString}`
|
||||
)
|
||||
},
|
||||
method: 'GET',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GetMonitorParams, GetMonitorResponse } from '@/tools/datadog/types'
|
||||
import { datadogErrorMessage } from '@/tools/datadog/utils'
|
||||
import { datadogErrorMessage, datadogPathSegment } from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getMonitorTool: ToolConfig<GetMonitorParams, GetMonitorResponse> = {
|
||||
@@ -56,7 +56,7 @@ export const getMonitorTool: ToolConfig<GetMonitorParams, GetMonitorResponse> =
|
||||
if (params.groupStates) queryParams.set('group_states', params.groupStates)
|
||||
if (params.withDowntimes) queryParams.set('with_downtimes', 'true')
|
||||
|
||||
const monitorId = encodeURIComponent(String(params.monitorId).trim())
|
||||
const monitorId = datadogPathSegment(params.monitorId)
|
||||
const queryString = queryParams.toString()
|
||||
return `https://api.${site}/api/v1/monitor/${monitorId}${queryString ? `?${queryString}` : ''}`
|
||||
},
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { GetSecuritySignalParams, GetSecuritySignalResponse } from '@/tools/datadog/types'
|
||||
import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils'
|
||||
import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getSecuritySignalTool: ToolConfig<GetSecuritySignalParams, GetSecuritySignalResponse> =
|
||||
@@ -41,7 +46,7 @@ export const getSecuritySignalTool: ToolConfig<GetSecuritySignalParams, GetSecur
|
||||
url: (params) =>
|
||||
datadogApiUrl(
|
||||
params.site,
|
||||
`/api/v2/security_monitoring/signals/${encodeURIComponent(params.signalId)}`
|
||||
`/api/v2/security_monitoring/signals/${datadogPathSegment(params.signalId)}`
|
||||
),
|
||||
method: 'GET',
|
||||
headers: datadogHeaders,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { GetSloParams, GetSloResponse } from '@/tools/datadog/types'
|
||||
import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils'
|
||||
import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getSloTool: ToolConfig<GetSloParams, GetSloResponse> = {
|
||||
@@ -46,7 +51,7 @@ export const getSloTool: ToolConfig<GetSloParams, GetSloResponse> = {
|
||||
const queryString = params.withConfiguredAlertIds ? '?with_configured_alert_ids=true' : ''
|
||||
return datadogApiUrl(
|
||||
params.site,
|
||||
`/api/v1/slo/${encodeURIComponent(params.sloId)}${queryString}`
|
||||
`/api/v1/slo/${datadogPathSegment(params.sloId)}${queryString}`
|
||||
)
|
||||
},
|
||||
method: 'GET',
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { GetSloHistoryParams, GetSloHistoryResponse } from '@/tools/datadog/types'
|
||||
import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils'
|
||||
import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getSloHistoryTool: ToolConfig<GetSloHistoryParams, GetSloHistoryResponse> = {
|
||||
@@ -72,7 +77,7 @@ export const getSloHistoryTool: ToolConfig<GetSloHistoryParams, GetSloHistoryRes
|
||||
queryParams.set('apply_correction', String(params.applyCorrection))
|
||||
return datadogApiUrl(
|
||||
params.site,
|
||||
`/api/v1/slo/${encodeURIComponent(params.sloId)}/history?${queryParams.toString()}`
|
||||
`/api/v1/slo/${datadogPathSegment(params.sloId)}/history?${queryParams.toString()}`
|
||||
)
|
||||
},
|
||||
method: 'GET',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
splitCommaList,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
@@ -76,7 +77,7 @@ export const getSyntheticsResultsTool: ToolConfig<
|
||||
const queryString = queryParams.toString()
|
||||
return datadogApiUrl(
|
||||
params.site,
|
||||
`/api/v1/synthetics/tests/${encodeURIComponent(params.publicId)}/results${
|
||||
`/api/v1/synthetics/tests/${datadogPathSegment(params.publicId)}/results${
|
||||
queryString ? `?${queryString}` : ''
|
||||
}`
|
||||
)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { GetSyntheticsTestParams, GetSyntheticsTestResponse } from '@/tools/datadog/types'
|
||||
import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils'
|
||||
import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getSyntheticsTestTool: ToolConfig<GetSyntheticsTestParams, GetSyntheticsTestResponse> =
|
||||
@@ -41,7 +46,7 @@ export const getSyntheticsTestTool: ToolConfig<GetSyntheticsTestParams, GetSynth
|
||||
url: (params) =>
|
||||
datadogApiUrl(
|
||||
params.site,
|
||||
`/api/v1/synthetics/tests/${encodeURIComponent(params.publicId)}`
|
||||
`/api/v1/synthetics/tests/${datadogPathSegment(params.publicId)}`
|
||||
),
|
||||
method: 'GET',
|
||||
headers: datadogHeaders,
|
||||
|
||||
@@ -45,13 +45,15 @@ export const listMonitorsTool: ToolConfig<ListMonitorsParams, ListMonitorsRespon
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Page number for pagination (0-indexed, e.g., 0, 1, 2)',
|
||||
description:
|
||||
'Page to start paginating from (0-indexed, e.g., 0, 1, 2). Datadog returns every monitor in the org without pagination when this is not specified, so set it to bound the response. Setting Page Size alone implies page 0.',
|
||||
},
|
||||
pageSize: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Number of monitors per page (e.g., 50, max: 1000)',
|
||||
description:
|
||||
'Number of monitors per page (e.g., 50, max: 1000). Datadog only applies this when a page is specified — otherwise it returns all monitors with no page size limit — so setting this alone sends page 0. With a page but no page size, Datadog defaults to 100.',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
@@ -83,7 +85,21 @@ export const listMonitorsTool: ToolConfig<ListMonitorsParams, ListMonitorsRespon
|
||||
if (params.tags) queryParams.set('tags', params.tags)
|
||||
if (params.monitorTags) queryParams.set('monitor_tags', params.monitorTags)
|
||||
if (params.withDowntimes) queryParams.set('with_downtimes', 'true')
|
||||
if (params.page !== undefined) queryParams.set('page', String(params.page))
|
||||
/**
|
||||
* Datadog ignores `page_size` unless `page` is also sent — without a page
|
||||
* it "returns all monitors without a `page_size` limit". A user who sets
|
||||
* only a page size gets every monitor in the org from a control that reads
|
||||
* as a bound, so imply the first page for them. `page` is not defaulted
|
||||
* when neither is set: that would silently truncate a caller who is
|
||||
* relying on the documented return-everything behavior.
|
||||
*/
|
||||
const page =
|
||||
params.page !== undefined && params.page !== null
|
||||
? params.page
|
||||
: params.pageSize
|
||||
? 0
|
||||
: undefined
|
||||
if (page !== undefined) queryParams.set('page', String(page))
|
||||
if (params.pageSize) queryParams.set('page_size', String(params.pageSize))
|
||||
|
||||
const queryString = queryParams.toString()
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { MuteMonitorParams, MuteMonitorResponse } from '@/tools/datadog/types'
|
||||
import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils'
|
||||
import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const muteMonitorTool: ToolConfig<MuteMonitorParams, MuteMonitorResponse> = {
|
||||
@@ -50,17 +55,28 @@ export const muteMonitorTool: ToolConfig<MuteMonitorParams, MuteMonitorResponse>
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* `scope` and `end` are query parameters, not a request body: the MuteMonitor
|
||||
* operation declares no `requestBody` and documents both under "Query
|
||||
* Strings". Sent as a body they are dropped, turning a scoped, time-boxed
|
||||
* mute into an indefinite mute across every scope — with a 200 and the full
|
||||
* monitor object back, so the caller never sees it.
|
||||
*/
|
||||
request: {
|
||||
url: (params) =>
|
||||
datadogApiUrl(params.site, `/api/v1/monitor/${encodeURIComponent(params.monitorId)}/mute`),
|
||||
url: (params) => {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params.scope) queryParams.set('scope', params.scope)
|
||||
if (params.end !== undefined && params.end !== null)
|
||||
queryParams.set('end', String(params.end))
|
||||
|
||||
const queryString = queryParams.toString()
|
||||
return datadogApiUrl(
|
||||
params.site,
|
||||
`/api/v1/monitor/${datadogPathSegment(params.monitorId)}/mute${queryString ? `?${queryString}` : ''}`
|
||||
)
|
||||
},
|
||||
method: 'POST',
|
||||
headers: datadogHeaders,
|
||||
body: (params) => {
|
||||
const body: { scope?: string; end?: number } = {}
|
||||
if (params.scope) body.scope = params.scope
|
||||
if (params.end !== undefined) body.end = params.end
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ export const sendLogsTool: ToolConfig<SendLogsParams, SendLogsResponse> = {
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'JSON array of log entries. Each entry should have message and optionally ddsource, ddtags, hostname, service.',
|
||||
'JSON array of log entries. Each entry should have message and optionally ddsource, ddtags, hostname, service. Sim fills in ddsource="custom" when an entry omits it — that is a Sim default, not a Datadog one; set ddsource yourself to have Datadog apply the matching integration log pipeline.',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
|
||||
@@ -510,8 +510,16 @@ export interface CreateSloResponse extends ToolResponse {
|
||||
output: CreateSloOutput
|
||||
}
|
||||
|
||||
export interface UpdateSloParams extends CreateSloParams {
|
||||
/**
|
||||
* `PUT /api/v1/slo/{slo_id}` is a full replacement that Sim fills from the stored SLO,
|
||||
* so every identifying field is optional here: an omitted `type` means "keep the stored
|
||||
* type" rather than "rewrite this SLO as a metric SLO".
|
||||
*/
|
||||
export interface UpdateSloParams extends Omit<CreateSloParams, 'name' | 'type' | 'thresholds'> {
|
||||
sloId: string
|
||||
name?: string
|
||||
type?: SloType
|
||||
thresholds?: string
|
||||
}
|
||||
|
||||
interface UpdateSloOutput {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { UnmuteMonitorParams, UnmuteMonitorResponse } from '@/tools/datadog/types'
|
||||
import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils'
|
||||
import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const unmuteMonitorTool: ToolConfig<UnmuteMonitorParams, UnmuteMonitorResponse> = {
|
||||
@@ -49,17 +54,27 @@ export const unmuteMonitorTool: ToolConfig<UnmuteMonitorParams, UnmuteMonitorRes
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* `scope` and `all_scopes` are query parameters, not a request body: the
|
||||
* UnmuteMonitor operation declares no `requestBody` and documents both under
|
||||
* "Query Strings". Sent as a body they are dropped, so "clear every scope"
|
||||
* silently never applies.
|
||||
*/
|
||||
request: {
|
||||
url: (params) =>
|
||||
datadogApiUrl(params.site, `/api/v1/monitor/${encodeURIComponent(params.monitorId)}/unmute`),
|
||||
url: (params) => {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params.scope) queryParams.set('scope', params.scope)
|
||||
if (params.allScopes !== undefined && params.allScopes !== null)
|
||||
queryParams.set('all_scopes', String(params.allScopes))
|
||||
|
||||
const queryString = queryParams.toString()
|
||||
return datadogApiUrl(
|
||||
params.site,
|
||||
`/api/v1/monitor/${datadogPathSegment(params.monitorId)}/unmute${queryString ? `?${queryString}` : ''}`
|
||||
)
|
||||
},
|
||||
method: 'POST',
|
||||
headers: datadogHeaders,
|
||||
body: (params) => {
|
||||
const body: { scope?: string; all_scopes?: boolean } = {}
|
||||
if (params.scope) body.scope = params.scope
|
||||
if (params.allScopes !== undefined) body.all_scopes = params.allScopes
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
parseJsonParam,
|
||||
splitCommaList,
|
||||
} from '@/tools/datadog/utils'
|
||||
@@ -99,7 +100,7 @@ export const updateIncidentTool: ToolConfig<UpdateIncidentParams, UpdateIncident
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
datadogApiUrl(params.site, `/api/v2/incidents/${encodeURIComponent(params.incidentId)}`),
|
||||
datadogApiUrl(params.site, `/api/v2/incidents/${datadogPathSegment(params.incidentId)}`),
|
||||
method: 'PATCH',
|
||||
headers: datadogHeaders,
|
||||
body: (params) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
mapSignalTriageData,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
@@ -58,7 +59,7 @@ export const updateSecuritySignalAssigneeTool: ToolConfig<
|
||||
url: (params) =>
|
||||
datadogApiUrl(
|
||||
params.site,
|
||||
`/api/v2/security_monitoring/signals/${encodeURIComponent(params.signalId)}/assignee`
|
||||
`/api/v2/security_monitoring/signals/${datadogPathSegment(params.signalId)}/assignee`
|
||||
),
|
||||
method: 'PATCH',
|
||||
headers: datadogHeaders,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
mapSignalTriageData,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
@@ -70,7 +71,7 @@ export const updateSecuritySignalStateTool: ToolConfig<
|
||||
url: (params) =>
|
||||
datadogApiUrl(
|
||||
params.site,
|
||||
`/api/v2/security_monitoring/signals/${encodeURIComponent(params.signalId)}/state`
|
||||
`/api/v2/security_monitoring/signals/${datadogPathSegment(params.signalId)}/state`
|
||||
),
|
||||
method: 'PATCH',
|
||||
headers: datadogHeaders,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
mergeSloUpdatePayload,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
@@ -111,7 +112,7 @@ export const updateSloTool: ToolConfig<UpdateSloParams, UpdateSloResponse> = {
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => datadogApiUrl(params.site, `/api/v1/slo/${encodeURIComponent(params.sloId)}`),
|
||||
url: (params) => datadogApiUrl(params.site, `/api/v1/slo/${datadogPathSegment(params.sloId)}`),
|
||||
method: 'PUT',
|
||||
headers: datadogHeaders,
|
||||
},
|
||||
@@ -122,7 +123,7 @@ export const updateSloTool: ToolConfig<UpdateSloParams, UpdateSloResponse> = {
|
||||
* erase every field the caller left blank.
|
||||
*/
|
||||
directExecution: async (params, signal) => {
|
||||
const url = datadogApiUrl(params.site, `/api/v1/slo/${encodeURIComponent(params.sloId)}`)
|
||||
const url = datadogApiUrl(params.site, `/api/v1/slo/${datadogPathSegment(params.sloId)}`)
|
||||
const headers = datadogHeaders(params)
|
||||
|
||||
const existingResponse = await fetch(url, { method: 'GET', headers, signal })
|
||||
|
||||
@@ -2,7 +2,12 @@ import type {
|
||||
UpdateSyntheticsStatusParams,
|
||||
UpdateSyntheticsStatusResponse,
|
||||
} from '@/tools/datadog/types'
|
||||
import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils'
|
||||
import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
} from '@/tools/datadog/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const updateSyntheticsStatusTool: ToolConfig<
|
||||
@@ -51,7 +56,7 @@ export const updateSyntheticsStatusTool: ToolConfig<
|
||||
url: (params) =>
|
||||
datadogApiUrl(
|
||||
params.site,
|
||||
`/api/v1/synthetics/tests/${encodeURIComponent(params.publicId)}/status`
|
||||
`/api/v1/synthetics/tests/${datadogPathSegment(params.publicId)}/status`
|
||||
),
|
||||
method: 'PUT',
|
||||
headers: datadogHeaders,
|
||||
|
||||
@@ -14,6 +14,18 @@ export function datadogApiUrl(site: DatadogSite | undefined, path: string): stri
|
||||
return `https://api.${site || 'datadoghq.com'}${path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes one user-supplied identifier for use as a URL path segment.
|
||||
*
|
||||
* IDs reach Sim by copy/paste and from `<Block.output>` references, so they arrive with
|
||||
* stray whitespace and as non-strings (a monitor ID is a number). `encodeURIComponent`
|
||||
* preserves the whitespace as `%20`, which Datadog treats as part of the ID and answers
|
||||
* with a 404 that names nothing the user typed — so trim before encoding.
|
||||
*/
|
||||
export function datadogPathSegment(value: unknown): string {
|
||||
return encodeURIComponent(String(value ?? '').trim())
|
||||
}
|
||||
|
||||
/** Standard Datadog authentication headers for API + application key auth. */
|
||||
export function datadogHeaders(params: {
|
||||
apiKey: string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user