Skip to content

Commit 2b88944

Browse files
committed
fix(v2-api): close two secret disclosures and align docs with signatures
Two P0 disclosures, five correctness bugs, and the standardization and guard work that came out of auditing them. **Secret disclosure — workflow version state.** `GET /api/v2/workflows/{id}/ versions/{version}` served the deployed graph unsanitized, so a read-role workspace API key received plaintext block-password values and OAuth credential ids. The sibling export route has always sanitized. Every other v2 response is protected structurally because the builder re-parses it, but this field is `z.custom<WorkflowState>()` — a predicate that validates nothing — which is why it survived earlier audits. Sanitization now lives in the use case, secure by default, with a named `includeCredentialValues` opt-in that only the session-authed deploy-preview route sets. **Secret disclosure — MCP headers.** The internal list and update routes returned custom `Authorization` headers verbatim to any read-role member; headers are stored unencrypted. Values are now gated on write permission and projected through one shared helper. The settings UI genuinely prefills from them, so blanking outright would wipe headers on unrelated edits — write-only headers plus encryption at rest are the follow-up. Correctness: - v2 execute ignored `X-Sim-Via`, resetting the call chain on every hop and defeating the recursion guard. Wired on both the keyed and anonymous paths. - v2 knowledge search accepted `searchMode` and dropped it, silently serving vector-only results for a hybrid request, and allowed 50MB bodies where internal caps at 2MiB. - v2 run cancel never released the plan concurrency slot and half-cancelled group runs; a group conflict now returns 409 instead of reporting success. - v2 table row writes stamped no secret provenance, so the next internal read reported the whole page incomplete. `secretProvenance` is now required on the primitives, making the next omission a compile error. - Folder conflicts and malformed paths returned 500; they are 409/404/400 now. `FolderPathError` splits from `FolderHierarchyError` so a corrupt stored tree stays a 500 and stays in 5xx alerting. Standardization and documentation: - `PUT /files/{id}/share` -> PATCH. The resource is not round-trippable (`hasPassword`, never the password), so merge-on-omission is the only implementable semantics. - ~40 spec truthfulness fixes: a 410 the API cannot emit, eight 423s with no lock guard, ~30 reachable-but-undocumented 404/400/413s, and six inverted field claims. Eleven operations that always reject a workspace key now say so — four of them answer 404, so a workspace key was told the resource did not exist. - `NAME_PATTERN` lost its `/i` through `z.toJSONSchema`, publishing 15 patterns that reject names the runtime accepts. Every generated client rejected any capitalized table or column name, and two of the spec's own examples failed the spec's own schema. Guards, so these classes cannot recur: - `check:route-verbs` (new) cross-checks all 212 builder routes' exported verb and path against their contract. The builders only compare at runtime, so a half-done rename previously passed CI and 500'd in production. - Example validation now runs against the published JSON Schema with formats on, covering 225 nodes instead of 100 — this is what caught the regex bug. - The list-pagination sweep is union-aware and fails loudly on a schema it cannot introspect, rather than counting it compliant.
1 parent 1dd85eb commit 2b88944

73 files changed

Lines changed: 3164 additions & 1075 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/openapi-v2-billing.json

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"get": {
3737
"operationId": "getBillingStatus",
3838
"summary": "Get Billing Status",
39-
"description": "Return the current plan, billing standing, credit allowance, and storage quota. Billing history lives at `GET /api/v2/billing/logs`.",
39+
"description": "Return the current plan, billing standing, credit allowance, and storage quota. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.",
4040
"tags": ["Billing"],
4141
"parameters": [
4242
{
@@ -46,7 +46,8 @@
4646
"description": "Workspace whose payer should be resolved. Workspace API keys are pinned to their own workspace.",
4747
"schema": {
4848
"description": "Workspace whose payer should be resolved. Workspace API keys are pinned to their own workspace.",
49-
"type": "string"
49+
"type": "string",
50+
"minLength": 1
5051
}
5152
}
5253
],
@@ -81,6 +82,9 @@
8182
"403": {
8283
"$ref": "#/components/responses/Forbidden"
8384
},
85+
"404": {
86+
"$ref": "#/components/responses/NotFound"
87+
},
8488
"429": {
8589
"$ref": "#/components/responses/RateLimited"
8690
},
@@ -128,7 +132,8 @@
128132
"description": "Restrict results to one workspace whose payer the caller can inspect.",
129133
"schema": {
130134
"description": "Restrict results to one workspace whose payer the caller can inspect.",
131-
"type": "string"
135+
"type": "string",
136+
"minLength": 1
132137
}
133138
},
134139
{
@@ -221,6 +226,9 @@
221226
"403": {
222227
"$ref": "#/components/responses/Forbidden"
223228
},
229+
"404": {
230+
"$ref": "#/components/responses/NotFound"
231+
},
224232
"429": {
225233
"$ref": "#/components/responses/RateLimited"
226234
},
@@ -240,7 +248,7 @@
240248
"type": "apiKey",
241249
"in": "header",
242250
"name": "X-API-Key",
243-
"description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys."
251+
"description": "Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those."
244252
}
245253
},
246254
"headers": {
@@ -381,7 +389,7 @@
381389
}
382390
},
383391
"PayloadTooLarge": {
384-
"description": "The request body exceeds the allowed size.",
392+
"description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.",
385393
"content": {
386394
"application/json": {
387395
"schema": {
@@ -425,6 +433,16 @@
425433
}
426434
}
427435
},
436+
"ClientClosedRequest": {
437+
"description": "The client closed the connection before the response was produced.",
438+
"content": {
439+
"application/json": {
440+
"schema": {
441+
"$ref": "#/components/schemas/V2Error"
442+
}
443+
}
444+
}
445+
},
428446
"InternalError": {
429447
"description": "An unexpected server error occurred.",
430448
"content": {
@@ -502,18 +520,18 @@
502520
"properties": {
503521
"start": {
504522
"type": "string",
505-
"description": "ISO 8601 start of the current billing period.",
523+
"description": "ISO 8601 start of the current billing period, or 1970-01-01T00:00:00.000Z when no Stripe subscription defines one.",
506524
"format": "date-time"
507525
},
508526
"end": {
509527
"type": "string",
510-
"description": "ISO 8601 end of the current billing period.",
528+
"description": "ISO 8601 end of the current billing period, or 9999-12-31T00:00:00.000Z when no Stripe subscription defines one.",
511529
"format": "date-time"
512530
}
513531
},
514532
"required": ["start", "end"],
515533
"additionalProperties": false,
516-
"description": "Current billing period."
534+
"description": "Current billing period. Only a Stripe subscription defines a real period; without one — notably on the free plan — this is the open interval 1970-01-01 to 9999-12-31 and must not be read as a monthly window."
517535
},
518536
"plan": {
519537
"type": "string",
@@ -529,20 +547,20 @@
529547
"properties": {
530548
"used": {
531549
"type": "number",
532-
"description": "Credits consumed during the current billing period."
550+
"description": "Credits consumed so far. The counter is reset by Stripe invoice webhooks, so on a paid plan it covers the current billing period; on the free plan nothing resets it and the value is lifetime consumption."
533551
},
534552
"limit": {
535553
"type": "number",
536-
"description": "Credit allowance for the current billing period."
554+
"description": "Credit allowance for the reporting window — per billing period on a paid plan, lifetime on the free plan."
537555
},
538556
"remaining": {
539557
"type": "number",
540-
"description": "Credits remaining in the current billing period."
558+
"description": "Allowance minus consumption, over the same window."
541559
}
542560
},
543561
"required": ["used", "limit", "remaining"],
544562
"additionalProperties": false,
545-
"description": "Credit usage and allowance for the current billing period."
563+
"description": "Credit usage and allowance. Periodic on a paid plan; lifetime on the free plan, where the counter never resets."
546564
},
547565
"storage": {
548566
"type": "object",
@@ -717,7 +735,7 @@
717735
"type": "null"
718736
}
719737
],
720-
"description": "Opaque cursor for the next page, or null when no more items remain."
738+
"description": "Opaque cursor for the next page, or null when no more items remain. Always null on a full-set list, which returns its whole result set in one response."
721739
}
722740
},
723741
"required": ["data", "nextCursor"],

apps/docs/openapi-v2-files-audit.json

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,9 @@
356356
}
357357
}
358358
},
359+
"400": {
360+
"$ref": "#/components/responses/BadRequest"
361+
},
359362
"401": {
360363
"$ref": "#/components/responses/Unauthorized"
361364
},
@@ -896,7 +899,7 @@
896899
"get": {
897900
"operationId": "listAuditLogs",
898901
"summary": "List Audit Logs",
899-
"description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access.",
902+
"description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.",
900903
"tags": ["Audit Logs"],
901904
"parameters": [
902905
{
@@ -1065,7 +1068,7 @@
10651068
"get": {
10661069
"operationId": "getAuditLog",
10671070
"summary": "Get Audit Log",
1068-
"description": "Return one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access.",
1071+
"description": "Return one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.",
10691072
"tags": ["Audit Logs"],
10701073
"parameters": [
10711074
{
@@ -1113,6 +1116,9 @@
11131116
}
11141117
}
11151118
},
1119+
"400": {
1120+
"$ref": "#/components/responses/BadRequest"
1121+
},
11161122
"401": {
11171123
"$ref": "#/components/responses/Unauthorized"
11181124
},
@@ -1277,10 +1283,10 @@
12771283
}
12781284
}
12791285
},
1280-
"put": {
1286+
"patch": {
12811287
"operationId": "upsertFileShare",
12821288
"summary": "Enable or Disable File Share",
1283-
"description": "Create or update a server-tokenized public share. Disabling retains its token and configuration for later re-enablement.",
1289+
"description": "Create or partially update a server-tokenized public share. Only isActive is required, and an omitted authType keeps the stored auth mode. What happens to password and allowedEmails depends on the resulting mode, because enabling a share always rewrites the credentials the chosen mode does not use: 'public' clears the stored password and empties allowedEmails; 'password' keeps the stored password when password is omitted but empties allowedEmails; 'email' and 'sso' clear the stored password and keep the stored allowedEmails when the field is omitted. Only disabling with isActive false preserves the whole access configuration untouched — it also retains the token, so re-enabling restores the share as it was. A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.",
12841290
"tags": ["Files"],
12851291
"parameters": [
12861292
{
@@ -1505,7 +1511,7 @@
15051511
"get": {
15061512
"operationId": "listFilesFolders",
15071513
"summary": "List Folders",
1508-
"description": "List workspace file folders with optional parent-path filtering and sorting.",
1514+
"description": "List workspace file folders with optional parent-path filtering and sorting. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.",
15091515
"tags": ["Files"],
15101516
"parameters": [
15111517
{
@@ -1600,9 +1606,6 @@
16001606
"404": {
16011607
"$ref": "#/components/responses/NotFound"
16021608
},
1603-
"409": {
1604-
"$ref": "#/components/responses/Conflict"
1605-
},
16061609
"429": {
16071610
"$ref": "#/components/responses/RateLimited"
16081611
},
@@ -1631,7 +1634,7 @@
16311634
}
16321635
},
16331636
"responses": {
1634-
"200": {
1637+
"201": {
16351638
"description": "The created folder.",
16361639
"headers": {
16371640
"X-RateLimit-Limit": {
@@ -1837,7 +1840,7 @@
18371840
"type": "apiKey",
18381841
"in": "header",
18391842
"name": "X-API-Key",
1840-
"description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys."
1843+
"description": "Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those."
18411844
}
18421845
},
18431846
"headers": {
@@ -2003,7 +2006,7 @@
20032006
}
20042007
},
20052008
"PayloadTooLarge": {
2006-
"description": "The request body exceeds the allowed size.",
2009+
"description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.",
20072010
"content": {
20082011
"application/json": {
20092012
"schema": {
@@ -2047,6 +2050,16 @@
20472050
}
20482051
}
20492052
},
2053+
"ClientClosedRequest": {
2054+
"description": "The client closed the connection before the response was produced.",
2055+
"content": {
2056+
"application/json": {
2057+
"schema": {
2058+
"$ref": "#/components/schemas/V2Error"
2059+
}
2060+
}
2061+
}
2062+
},
20502063
"InternalError": {
20512064
"description": "An unexpected server error occurred.",
20522065
"content": {
@@ -2192,7 +2205,7 @@
21922205
"type": "null"
21932206
}
21942207
],
2195-
"description": "Opaque cursor for the next page, or null when no more items remain."
2208+
"description": "Opaque cursor for the next page, or null when no more items remain. Always null on a full-set list, which returns its whole result set in one response."
21962209
}
21972210
},
21982211
"required": ["data", "nextCursor"],
@@ -2272,7 +2285,7 @@
22722285
},
22732286
"content": {
22742287
"default": "",
2275-
"description": "Initial file content. Omit or send an empty string for a zero-byte file.",
2288+
"description": "Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound is a JSON-envelope guard, not the file-size limit: the decoded bytes must be at most 50 MiB, so a longer base64 payload is admitted here and then rejected with 413. Use an upload session for anything larger.",
22762289
"type": "string",
22772290
"maxLength": 70000000
22782291
},
@@ -2980,7 +2993,7 @@
29802993
"type": "null"
29812994
}
29822995
],
2983-
"description": "Opaque cursor for the next page, or null when no more items remain."
2996+
"description": "Opaque cursor for the next page, or null when no more items remain. Always null on a full-set list, which returns its whole result set in one response."
29842997
}
29852998
},
29862999
"required": ["data", "nextCursor"],
@@ -3276,7 +3289,7 @@
32763289
"content": {
32773290
"type": "string",
32783291
"maxLength": 70000000,
3279-
"description": "Complete replacement content for the file."
3292+
"description": "Complete replacement content for the file. The 70,000,000-character bound is a JSON-envelope guard, not the file-size limit: the decoded bytes must be at most 50 MiB, so a longer base64 payload is admitted here and then rejected with 413."
32803293
},
32813294
"encoding": {
32823295
"default": "utf-8",
@@ -3421,7 +3434,7 @@
34213434
"type": "null"
34223435
}
34233436
],
3424-
"description": "Opaque cursor for the next page, or null when no more items remain."
3437+
"description": "Opaque cursor for the next page, or null when no more items remain. Always null on a full-set list, which returns its whole result set in one response."
34253438
}
34263439
},
34273440
"required": ["data", "nextCursor"],

0 commit comments

Comments
 (0)