Repository navigation
Conversation
Serve GET, POST, PUT and DELETE on /organizations/{organization_id}/connected_accounts/{slug}.
An organization holds one account per provider with user_id null, apart from any its members
hold. Import and update name the acting member in user_id, who must be an active member.
Accounts now report established_by_user_id, cleared when that user is deleted. Deleting an
organization deletes its accounts and its members' accounts scoped to it. The connectedAccounts
seed takes owner: organization.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Describe the organization-owned rules in prose, as the other behavior sections do. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
An overlapping import otherwise misses the other's write and both answer 201. Cover the members' scoped accounts in the organization-delete cascade test, and say plainly that ending a membership does not clear credentials, unlike production. Co-Authored-By: Claude Opus 5.5 <[email protected]>
As CLAUDE.md asks, and cite production's invalidation when the establishing member leaves. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Serve POST /data-integrations/{slug}/credentials. It returns the account's token as a
credential, or active: false with not_installed or needs_reauthorization. With
connection_owner: organization it vends the organization's account. Naming an organization
the user is not an active member of returns 403 for either owner, and a malformed body
returns the spec's 400.
/credentials and /token share the token logic. /token is otherwise unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…olves A request that leaves organization_id out still resolves a user's only account for the provider, and that account may be scoped to an organization the user has left. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
[Medium impact] The changes reach the Pipes account connection and credential workflows. The PR appears safe to merge, with two non-blocking fixes for the interactive page and malformed-body errors. FindingsFix with agent prompt### Issue 1
src/workos/routes/data-integrations.ts:207-218
Under `--interactive`, opening a link after the user's organization membership ends still shows the Allow page with status 200. The membership check only runs in `connect()`, after the user submits the form. Check membership before rendering the page too, so the visit returns the documented 403 instead of offering a connection that will fail.
### Issue 2
src/workos/routes/data-integrations.ts:102
Invalid JSON sent to `/authorize` or `/credentials` bypasses `badRequest()`. `parseJsonBody()` throws a `WorkOSApiError`, so the response contains `code: "invalid_request_body"` but lacks the documented `error: "Bad Request"`. Clients checking that field get a different response than they do for invalid body fields. Use the promised error shape for parse failures too, and add an invalid-JSON test for both routes.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryAdds authorize links and credential retrieval for user- and organization-owned connected accounts.
|
| if (store.getData<boolean>(STORE_KEYS.interactiveAuth)) { | ||
| const organization = authorization.organization_id | ||
| ? ws.organizations.get(authorization.organization_id) | ||
| : undefined; | ||
| return c.html( | ||
| renderConnectPage({ | ||
| slug: authorization.slug, | ||
| email: ws.users.get(authorization.user_id)!.email, | ||
| organizationName: organization?.name, | ||
| formAction: new URL(c.req.url).pathname, | ||
| }), | ||
| ); |
There was a problem hiding this comment.
Former members still see Allow
Under --interactive, opening a link after the user's organization membership ends still shows the Allow page with status 200. The membership check only runs in connect(), after the user submits the form. Check membership before rendering the page too, so the visit returns the documented 403 instead of offering a connection that will fail.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/workos/routes/data-integrations.ts
Line: 207-218
Comment:
**Former members still see Allow**
Under `--interactive`, opening a link after the user's organization membership ends still shows the Allow page with status 200. The membership check only runs in `connect()`, after the user submits the form. Check membership before rendering the page too, so the visit returns the documented 403 instead of offering a connection that will fail.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| // The spec's authorize: returns a URL to send the browser to, which connects the account. | ||
| app.post('/data-integrations/:slug/authorize', async (c) => { | ||
| const slug = c.req.param('slug'); | ||
| const body = await parseJsonBody(c); |
There was a problem hiding this comment.
Invalid JSON gets another shape
Invalid JSON sent to /authorize or /credentials bypasses badRequest(). parseJsonBody() throws a WorkOSApiError, so the response contains code: "invalid_request_body" but lacks the documented error: "Bad Request". Clients checking that field get a different response than they do for invalid body fields. Use the promised error shape for parse failures too, and add an invalid-JSON test for both routes.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/workos/routes/data-integrations.ts
Line: 102
Comment:
**Invalid JSON gets another shape**
Invalid JSON sent to `/authorize` or `/credentials` bypasses `badRequest()`. `parseJsonBody()` throws a `WorkOSApiError`, so the response contains `code: "invalid_request_body"` but lacks the documented `error: "Bad Request"`. Clients checking that field get a different response than they do for invalid body fields. Use the promised error shape for parse failures too, and add an invalid-JSON test for both routes.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Similar to the comment in #178, this is the convention of other endpoints
There was a problem hiding this comment.
Agreed. We settled the same point on PR #178: parseJsonBody errors keep the shared parser response, while field-validation errors use badRequest(). I’ll withdraw this finding rather than repeat it.
…0 row The 400 with "error": "Bad Request" answers a missing or invalid field; a body that is not JSON gets the emulator-wide invalid_request_body, as on every route. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Serve POST /data-integrations/{slug}/authorize. It returns a url whose page connects the
account and redirects to return_to, or shows a confirmation page without one. Under
--interactive the page asks first, standing in for the provider's consent screen. The link
works once, expires after 10 minutes, and is deleted with its user or organization. A
membership that has ended by the time the link is opened returns 403.
Only GET /data-integrations/{slug}/authorize and the new page are public. Before, any method
on a path ending in /authorize skipped the API key.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Redirect to return_to through URL, as the logout redirect does, so a space is encoded in Location. Issuing a link deletes the user's expired ones, which were only dropped when visited. Cover the 10-minute expiry and a reconnect dropping the stored tokens. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Under --interactive the page rendered for a user whose membership had ended, and only submitting it answered 403. Both authorize-redirect handlers now check first. Co-Authored-By: Claude Opus 5.5 <[email protected]>
41310ac to
0294f64
Compare
Stacks on #178
Summary
POST /data-integrations/{slug}/authorize, which returns{ url }.urlconnects the account itself, as /user_management/authorize completes without an IdP.return_to, or shows a confirmation page without one. Under--interactiveit shows an Allow page first.established_by_user_id. A reconnect keeps the account's id and drops its stored tokens.return_tois checked against the allowed redirect hosts, and re-serialized before the redirect.GET /data-integrations/{slug}/authorize(the legacy redirect) and the new page are public.Behaviour
/authorizewith a malformed body400,"error": "Bad Request"/authorizenaming an organization the user is not an active member of403/authorizewithout an API key401url302toreturn_toor a confirmation pageurlunder--interactiveurl400pageurlafter the user's membership of the organization ended403page, nothing connectedVerification
Full suite: 1937 pass, 0 fail.
bun run typecheck,bun run lint,bun run fmt:checkandbun run gen:supportedare clean. New tests indata-integrations.spec.tscover the full flow forboth owners, reconnecting, the confirmation page, used, expired and swept links, both cascades, the
ended-membership page, the interactive page,
return_torefusal and encoding, the API key, andthe user-owned flow through the Node SDK's
authorizeDataIntegration.I also built the image from the
Dockerfileat this branch's head and called the routes from anexternal Python client. It got the not-installed answer, the 403 for a non-member, a connect
through the URL, a token, a disconnect, and the empty state after it.
Closes #176.