Skip to content

Commit 756310e

Browse files
committed
Merge remote-tracking branch 'origin/staging' into HEAD
# Conflicts: # scripts/check-api-validation-contracts.ts
2 parents ae57d2f + 128054e commit 756310e

62 files changed

Lines changed: 8586 additions & 64 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.

.agents/skills/v2-api-conventions/SKILL.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ failure (always) { "error": { "code": "...", "message": "...", "details"?:
1616

1717
Nothing else at the top level. No `success: true`, no bare `{ "error": "string" }`, no HTML.
1818

19-
That promise is worth stating as a rule because it has been broken four separate ways, each time by a route or a builder taking a shortcut that looked local:
19+
That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local:
2020

2121
- `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`.
2222
- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered.
2323
- `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page.
2424
- Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`.
25+
- Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was.
2526

2627
Each was one line. The rules below are the generalisations.
2728

@@ -62,7 +63,11 @@ Two of these carry real design weight:
6263

6364
**404 is deliberately overloaded.** A workspace the caller cannot reach answers `404 "Workspace not found"`, never 403 — a 403 would confirm the resource exists. `createV2ResourceConcealmentPolicy` does this by mapping a cross-tenant authorization failure to `v2Error('NOT_FOUND', ...)`. The rollout gate answers the same way for the same reason (`gate.ts`: "an ungated caller cannot distinguish 'not in the rollout cohort' from 'no such endpoint'"), and so does the unknown-path catch-all at `app/api/v2/[[...segments]]/route.ts` — its body is byte-identical to the gate's on purpose.
6465

65-
**500 is never caller-reachable.** Any input a caller can send must be rejected at the contract boundary with a 400. If you can construct a query string or body that produces a 500, that is a bug in the contract, not something to wrap in a `try`/`catch`. `v2ErrorForOrchestration` also replaces the message on an unclassified failure with a generic one, so internal detail never leaks. A caller-reachable 500 has shipped twice — a fractional `limit` reaching `LIMIT 2.5`, and a plain `HEAD` tripping the builder's method guard — so treat "a well-formed request produced a 500" as the highest-severity class of defect on this surface.
66+
**500 is never caller-reachable.** Any input a caller can send must be rejected at the contract boundary with a 400. If you can construct a query string or body that produces a 500, that is a bug in the contract, not something to wrap in a `try`/`catch`. `v2ErrorForOrchestration` also replaces the message on an unclassified failure with a generic one, so internal detail never leaks. A caller-reachable 500 has shipped three times — a fractional `limit` reaching `LIMIT 2.5`, a plain `HEAD` tripping the builder's method guard, and a keyset cursor's timestamp reaching `date_trunc` as an untyped placeholder — so treat "a well-formed request produced a 500" as the highest-severity class of defect on this surface.
67+
68+
**Validating a value is only half of it; the value also has to reach SQL with a type.** A bound parameter arrives as `unknown` and takes its type from context. Against a typed column (`sort_order > $1`) that inference always succeeds, which is why the gap stays invisible almost everywhere — but as an argument to an overloaded function it can resolve to nothing at all. So: **if a bound value is an argument to a SQL function rather than one side of a comparison, write its type down** (`lib/api/list-query.ts`, `timestampKey`, casts from the column).
69+
70+
And this class survives a green test suite — `keysetAfter` returned well-formed SQL and every assertion passed; only Postgres's parser rejected it. When a change alters the *shape* of generated SQL rather than its values, execute it somewhere before believing the suite.
6671

6772
**Which of 403 and 404 an operation documents follows from its authorization, not from whether it is a read.** `requirePermission` throws two different failures: no workspace access at all is `NoWorkspaceAccessError`, which `createV2ResourceConcealmentPolicy` conceals as 404; access below the operation's `minimumRole` is `InsufficientWorkspacePermissionsError`, which stays a 403. So:
6873

@@ -194,7 +199,7 @@ Run this against any new or changed v2 endpoint.
194199
- [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`.
195200
- [ ] Route uses a shared builder; no hand-built `NextResponse.json`.
196201
- [ ] Query and body schemas are `.strict()`.
197-
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer.
202+
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type.
198203
- [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`.
199204
- [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them.
200205
- [ ] Keyset sorts end in a unique `id` key.

.claude/commands/v2-api-conventions.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ failure (always) { "error": { "code": "...", "message": "...", "details"?:
1515

1616
Nothing else at the top level. No `success: true`, no bare `{ "error": "string" }`, no HTML.
1717

18-
That promise is worth stating as a rule because it has been broken four separate ways, each time by a route or a builder taking a shortcut that looked local:
18+
That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local:
1919

2020
- `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`.
2121
- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered.
2222
- `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page.
2323
- Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`.
24+
- Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was.
2425

2526
Each was one line. The rules below are the generalisations.
2627

@@ -61,7 +62,11 @@ Two of these carry real design weight:
6162

6263
**404 is deliberately overloaded.** A workspace the caller cannot reach answers `404 "Workspace not found"`, never 403 — a 403 would confirm the resource exists. `createV2ResourceConcealmentPolicy` does this by mapping a cross-tenant authorization failure to `v2Error('NOT_FOUND', ...)`. The rollout gate answers the same way for the same reason (`gate.ts`: "an ungated caller cannot distinguish 'not in the rollout cohort' from 'no such endpoint'"), and so does the unknown-path catch-all at `app/api/v2/[[...segments]]/route.ts` — its body is byte-identical to the gate's on purpose.
6364

64-
**500 is never caller-reachable.** Any input a caller can send must be rejected at the contract boundary with a 400. If you can construct a query string or body that produces a 500, that is a bug in the contract, not something to wrap in a `try`/`catch`. `v2ErrorForOrchestration` also replaces the message on an unclassified failure with a generic one, so internal detail never leaks. A caller-reachable 500 has shipped twice — a fractional `limit` reaching `LIMIT 2.5`, and a plain `HEAD` tripping the builder's method guard — so treat "a well-formed request produced a 500" as the highest-severity class of defect on this surface.
65+
**500 is never caller-reachable.** Any input a caller can send must be rejected at the contract boundary with a 400. If you can construct a query string or body that produces a 500, that is a bug in the contract, not something to wrap in a `try`/`catch`. `v2ErrorForOrchestration` also replaces the message on an unclassified failure with a generic one, so internal detail never leaks. A caller-reachable 500 has shipped three times — a fractional `limit` reaching `LIMIT 2.5`, a plain `HEAD` tripping the builder's method guard, and a keyset cursor's timestamp reaching `date_trunc` as an untyped placeholder — so treat "a well-formed request produced a 500" as the highest-severity class of defect on this surface.
66+
67+
**Validating a value is only half of it; the value also has to reach SQL with a type.** A bound parameter arrives as `unknown` and takes its type from context. Against a typed column (`sort_order > $1`) that inference always succeeds, which is why the gap stays invisible almost everywhere — but as an argument to an overloaded function it can resolve to nothing at all. So: **if a bound value is an argument to a SQL function rather than one side of a comparison, write its type down** (`lib/api/list-query.ts`, `timestampKey`, casts from the column).
68+
69+
And this class survives a green test suite — `keysetAfter` returned well-formed SQL and every assertion passed; only Postgres's parser rejected it. When a change alters the *shape* of generated SQL rather than its values, execute it somewhere before believing the suite.
6570

6671
**Which of 403 and 404 an operation documents follows from its authorization, not from whether it is a read.** `requirePermission` throws two different failures: no workspace access at all is `NoWorkspaceAccessError`, which `createV2ResourceConcealmentPolicy` conceals as 404; access below the operation's `minimumRole` is `InsufficientWorkspacePermissionsError`, which stays a 403. So:
6772

@@ -193,7 +198,7 @@ Run this against any new or changed v2 endpoint.
193198
- [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`.
194199
- [ ] Route uses a shared builder; no hand-built `NextResponse.json`.
195200
- [ ] Query and body schemas are `.strict()`.
196-
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer.
201+
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type.
197202
- [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`.
198203
- [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them.
199204
- [ ] Keyset sorts end in a unique `id` key.

.cursor/commands/v2-api-conventions.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ failure (always) { "error": { "code": "...", "message": "...", "details"?:
1010

1111
Nothing else at the top level. No `success: true`, no bare `{ "error": "string" }`, no HTML.
1212

13-
That promise is worth stating as a rule because it has been broken four separate ways, each time by a route or a builder taking a shortcut that looked local:
13+
That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local:
1414

1515
- `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`.
1616
- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered.
1717
- `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page.
1818
- Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`.
19+
- Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was.
1920

2021
Each was one line. The rules below are the generalisations.
2122

@@ -56,7 +57,11 @@ Two of these carry real design weight:
5657

5758
**404 is deliberately overloaded.** A workspace the caller cannot reach answers `404 "Workspace not found"`, never 403 — a 403 would confirm the resource exists. `createV2ResourceConcealmentPolicy` does this by mapping a cross-tenant authorization failure to `v2Error('NOT_FOUND', ...)`. The rollout gate answers the same way for the same reason (`gate.ts`: "an ungated caller cannot distinguish 'not in the rollout cohort' from 'no such endpoint'"), and so does the unknown-path catch-all at `app/api/v2/[[...segments]]/route.ts` — its body is byte-identical to the gate's on purpose.
5859

59-
**500 is never caller-reachable.** Any input a caller can send must be rejected at the contract boundary with a 400. If you can construct a query string or body that produces a 500, that is a bug in the contract, not something to wrap in a `try`/`catch`. `v2ErrorForOrchestration` also replaces the message on an unclassified failure with a generic one, so internal detail never leaks. A caller-reachable 500 has shipped twice — a fractional `limit` reaching `LIMIT 2.5`, and a plain `HEAD` tripping the builder's method guard — so treat "a well-formed request produced a 500" as the highest-severity class of defect on this surface.
60+
**500 is never caller-reachable.** Any input a caller can send must be rejected at the contract boundary with a 400. If you can construct a query string or body that produces a 500, that is a bug in the contract, not something to wrap in a `try`/`catch`. `v2ErrorForOrchestration` also replaces the message on an unclassified failure with a generic one, so internal detail never leaks. A caller-reachable 500 has shipped three times — a fractional `limit` reaching `LIMIT 2.5`, a plain `HEAD` tripping the builder's method guard, and a keyset cursor's timestamp reaching `date_trunc` as an untyped placeholder — so treat "a well-formed request produced a 500" as the highest-severity class of defect on this surface.
61+
62+
**Validating a value is only half of it; the value also has to reach SQL with a type.** A bound parameter arrives as `unknown` and takes its type from context. Against a typed column (`sort_order > $1`) that inference always succeeds, which is why the gap stays invisible almost everywhere — but as an argument to an overloaded function it can resolve to nothing at all. So: **if a bound value is an argument to a SQL function rather than one side of a comparison, write its type down** (`lib/api/list-query.ts`, `timestampKey`, casts from the column).
63+
64+
And this class survives a green test suite — `keysetAfter` returned well-formed SQL and every assertion passed; only Postgres's parser rejected it. When a change alters the *shape* of generated SQL rather than its values, execute it somewhere before believing the suite.
6065

6166
**Which of 403 and 404 an operation documents follows from its authorization, not from whether it is a read.** `requirePermission` throws two different failures: no workspace access at all is `NoWorkspaceAccessError`, which `createV2ResourceConcealmentPolicy` conceals as 404; access below the operation's `minimumRole` is `InsufficientWorkspacePermissionsError`, which stays a 403. So:
6267

@@ -188,7 +193,7 @@ Run this against any new or changed v2 endpoint.
188193
- [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`.
189194
- [ ] Route uses a shared builder; no hand-built `NextResponse.json`.
190195
- [ ] Query and body schemas are `.strict()`.
191-
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer.
196+
- [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type.
192197
- [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`.
193198
- [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them.
194199
- [ ] Keyset sorts end in a unique `id` key.

apps/docs/components/icons.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2494,6 +2494,32 @@ export function DocumentIcon(props: SVGProps<SVGSVGElement>) {
24942494
)
24952495
}
24962496

2497+
export function WindchillIcon(props: SVGProps<SVGSVGElement>) {
2498+
return (
2499+
<svg
2500+
viewBox='79 57 88 88'
2501+
role='img'
2502+
aria-label='Windchill icon'
2503+
xmlns='http://www.w3.org/2000/svg'
2504+
{...props}
2505+
>
2506+
<rect x='79' y='57' width='88' height='88' fill='#FFFFFF' />
2507+
<g fill='#3D4647'>
2508+
<polygon points='137.2,86.6 137.2,68.5 122.8,60.2 107.1,69.2' />
2509+
<polygon points='142.7,106.4 158.4,97.3 158.4,80.7 142.7,71.6' />
2510+
<polygon points='108.5,115.8 108.5,133.9 122.8,142.2 138.5,133.2' />
2511+
<polygon points='128.2,121 143.9,130.1 158.4,121.7 158.4,103.6' />
2512+
<polygon points='117.4,81.4 101.7,72.4 87.3,80.7 87.3,98.8' />
2513+
<polygon points='103,96 87.3,105.1 87.3,121.7 103,130.8' />
2514+
</g>
2515+
<polygon
2516+
fill='#40AA1D'
2517+
points='137.2,109.5 122.8,117.8 108.4,109.5 108.4,92.9 122.8,84.6 137.2,92.9'
2518+
/>
2519+
</svg>
2520+
)
2521+
}
2522+
24972523
export function MintlifyIcon(props: SVGProps<SVGSVGElement>) {
24982524
return (
24992525
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 19 19' fill='none'>

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ import {
245245
WebhookIcon,
246246
WhatsAppIcon,
247247
WikipediaIcon,
248+
WindchillIcon,
248249
WizaIcon,
249250
WordpressIcon,
250251
WorkdayIcon,
@@ -538,6 +539,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
538539
webflow: WebflowIcon,
539540
whatsapp: WhatsAppIcon,
540541
wikipedia: WikipediaIcon,
542+
windchill: WindchillIcon,
541543
wiza: WizaIcon,
542544
wordpress: WordpressIcon,
543545
workday: WorkdayIcon,

apps/docs/content/docs/en/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@
259259
"webflow-service-account",
260260
"whatsapp",
261261
"wikipedia",
262+
"windchill",
262263
"wiza",
263264
"wordpress",
264265
"workday",

0 commit comments

Comments
 (0)