Skip to content

Commit 744211f

Browse files
feat(spec): ConnectorConnectionState declares redirectUri, the derived OAuth redirect URI (#29618)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8NW1CM3VW93waPbnVevLEfG)_ Fixes #29574 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:`ConnectorConnectionState` 新增一个可选只读键 `redirectUri`,承载本部署推导出的 OAuth 回调地址;只改 spec 契约,不改服务端。 - 为什么改:管理员要先拿这个地址去厂商处注册自己的 OAuth client(#28702 裁决 B),Console 的连接步骤需要在有 client 之前就显示它;此前没有任何回答带它。 - 风险与回滚:纯加宽,一个可选键,已有实现与调用方零改动;回滚即 revert 本 PR。服务端尚未填它(#29463),TSDoc 已写明「声明在先」。 - 先看:`packages/spec/src/contracts/automation-service.ts`(新键及 TSDoc)、`packages/spec/src/contracts/connector-connection-redirect-uri.pin.test.ts`、`.changeset/29574-connector-redirect-uri.md`。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/contracts/connector-connection-redirect-uri.pin.test.ts`,预期 2 passed。 - 需要你做的:批准键名 `redirectUri`(或改选下方 Open questions 里的其它拼法);批准原文须引入本 PR 正文后才能入队。 ## What changes `ConnectorConnectionState` (`@objectstack/spec/contracts`, `packages/spec/src/contracts/automation-service.ts`) declares one optional key, `redirectUri?: string`: the absolute URL of `GET /api/v1/automation/connectors/oauth/callback` that the administrator registers with the vendor's OAuth client. Its TSDoc states: - **How it is derived:** by the server, fixed per deployment, never taken from a request: the automation plugin's `connectorOAuthRedirectUri` option, else that path under `OS_AUTH_URL`, else under `OS_BASE_URL` (the chain `ServiceAutomationPlugin.connectorOAuthRedirectUri()` already implements). - **When it is present:** on `getConnectorConnection`'s answer whenever the server can derive it, whatever `status` says. - **When it is absent:** when the deployment has no base to derive it from (a connect is then refused `503`), never guessed from the request's origin; and for every grant other than `authorization_code`, structurally, since only an `authorization_code` instance has a connection (the door refuses any other `400`). - **Read-only:** no connection request takes it. - **Declared ahead of the service:** until the automation service sets it (#29463, `Blocked-by` this card), no answer carries it. #29463's PR deletes that sentence when it lands. No server change, no docs change (the card leaves `content/docs/automation/connectors.mdx` to #29463), no `api/protocol.zod.ts` / `identity/protocol.ts` / metadata-protocol `protocol.ts` edit (freeze until 2026-11-03 06:00Z). The interface has no Zod wire mirror in `packages/spec/src/api/`, so no schema or generated reference moves. ## Why #28702 ruling B (6566728470) has each deployment's administrator register their own OAuth client against this deployment's callback, and asks the Console's Connect step (#28869) to show that exact URI with a copy button. The server derives it but answers it nowhere a client can read before a client exists. Split from #29463 on route ruling 6585200349; triage 6586696710 (p1). ## Open questions - **The key name (merge gate: the maintainer approves it before the queue).** Proposed `redirectUri`. Reasoning, by the method the `client` ruling 6580268872 used (reuse the words the contract layer already uses for the concept): - It is the RFC 6749 `redirect_uri` parameter, camelCased per the config-key rule. - The code that derives and sends this exact value already spells it `redirectUri`: the plugin option `connectorOAuthRedirectUri`, and the request fields of `services/service-automation/src/oauth2-grant/authorization.ts` and `mcp-authorization.ts`. The vendor consoles an administrator pastes it into call it "Redirect URI". - Not `redirectUrl`: in the spec that word is the action handler-return navigation convention (`{ redirectUrl, openIn? }`, `ui/action.zod.ts`), a different concept a reader would conflate. - Not `callbackUrl`: no precedent in the spec, and it names the route rather than the OAuth term the vendor asks for. - Not nested as `client.redirectUri`: `client` is absent until the installer enters one, which is exactly when the URI is needed. - Noted: `redirectUri` once named an inline key of the retired `ConnectorOAuth2Schema` (`connector.authentication`, migration entry `connector-auth-config-oauth2-retired`). That was an authorable metadata key; this is a read-only answer field on a service contract, never authored, so nothing collides with that retirement. - Options: **A** `redirectUri` (recommended, for the reasons above) · **B** `callbackUrl` · **C** `redirectUrl`. ## Tests - New `packages/spec/src/contracts/connector-connection-redirect-uri.pin.test.ts`, compiled by `check:test-typecheck` under `tsconfig.test.json`: the key is exactly `string | undefined` and optional; `ConnectorConnectionRequest` has no such key; every prior key of the state keeps its type; a structured value does not compile (`@ts-expect-error`); the TSDoc names the callback path, the derivation chain and both absence conditions. - Reverse check, run once: with the key mutated to `number` (`scripts/ablation-replace.mjs`), `check:test-typecheck` went red with "2 type error(s)" in the pin file; restored blob equals HEAD (`31cf455e60bc`). ## Acceptance notes - No `readonly` modifier: "read-only" here means no request carries it (pinned); the interface's sibling keys carry none either. - `Metadata-protocol: none`: `ConnectorConnectionState` is a service answer, not something a metadata author writes. `Clause-②: yes (widening)`: the published contract gains a key. ## Verification All readings at head `555302a1f` (this branch merged with `origin/main` at the time of the run), in `objectstack-ai/objectstack`: - `pnpm --filter @objectstack/spec build` :: exit 0 (lock VERDICT command-exit 0). - `pnpm --filter @objectstack/spec check:generated` :: exit 0, "All 14 generated artifacts are up to date"; nothing regenerated, nothing committed (the interface has no Zod mirror, and the api-surface row `ConnectorConnectionState (interface)` names the interface, not its members). - `pnpm --filter @objectstack/spec test` :: exit 0, Test Files 802 passed (802), Tests 23700 passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck` :: exit 0 (includes `check:test-typecheck`, which compiles the new pin). - `node scripts/pm/dispatch-gates.mjs --commands`: 79 derived families; all 79 run, each exit 0; `--ran` reconciliation: "79 derived famil(ies) accounted for — 79 run, 0 NOT-MEASURED". Prerequisite builds: `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint --filter=@objectstack/objectql`, and `git fetch origin 621a487` for the teardown-shape self-test's pinned control. - Every family CI runs beyond that local subset (repo-wide lint, Test Core, Build Core, the type-check lanes): this PR's check-runs. - Dogfood: not owed (no `!` in the title, no `(narrowing)` arm). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (A contract interface with no Zod mirror, as its siblings.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Repo-wide lint is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (`check:generated`: all 14 up to date, nothing moved.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbXFQf118bS56KJqpQj)_ ## Maintainer approval Warren approved the key name `redirectUri` (on `ConnectorConnectionState`, option A; `callbackUrl` and `redirectUrl` were not taken) on 2026-11-03 at 00:08 UTC. He chose 「全部批准」 on decision card cmsg_01U5RdnAAmkSQmZbqh6FtqZ8Rr7w8AxYJLZP6jP9v41T4n, whose question was 「六个命名/措辞全部按推荐批准吗?」, in the project thread "Decide pending spec name approvals". The post above that card recommended `redirectUri` as spelled. <!-- signed: domain:spec#5 · os-warren · session_01JQwxbWR3K3h7i1ytwycVg1 --> Co-authored-by: Claude <noreply@anthropic.com>
1 parent e0187d0 commit 744211f

3 files changed

Lines changed: 130 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
feat(spec): `ConnectorConnectionState` declares `redirectUri`, the deployment's derived OAuth redirect URI
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
An administrator connecting an `authorization_code` connector instance with an installer-supplied client (`clientSource: 'installer'`) must register this deployment's redirect URI with the vendor before there is a client to connect with. The server derives that URI, but no answer carried it. `ConnectorConnectionState` (`@objectstack/spec/contracts`) now declares one optional key for it:
11+
12+
- **`redirectUri?: string`** is the absolute URL of `GET /api/v1/automation/connectors/oauth/callback`, derived by the server and fixed per deployment: the automation plugin's `connectorOAuthRedirectUri` option, else that path under `OS_AUTH_URL`, else under `OS_BASE_URL`. It is never taken from a request, and no connection request takes it.
13+
- **Present** on the connection read (`getConnectorConnection`) whenever the server can derive it, whatever the connection's `status`. **Absent** when the deployment has no base to derive it from (a connect is then refused `503`), and never guessed from the request's origin. Only an `authorization_code` instance has a connection, so no other grant carries it.
14+
- **Declared ahead of the service.** This release adds the contract only: until the automation service sets the key, no answer carries it. Nothing an implementer or caller writes today stops compiling.

‎packages/spec/src/contracts/automation-service.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,6 +1178,26 @@ export interface ConnectorConnectionState {
11781178
clientId: string;
11791179
hasSecret: boolean;
11801180
};
1181+
/**
1182+
* ADR-0143 D5 — the redirect URI this deployment's connect names: the
1183+
* absolute URL of `GET /api/v1/automation/connectors/oauth/callback` that
1184+
* the administrator registers with the vendor's OAuth client, answered so
1185+
* the Connect step can show it before any client is entered (ADR-0143 D2.1,
1186+
* `clientSource: 'installer'`). Derived by the server and fixed per
1187+
* deployment, never taken from a request: the automation plugin's
1188+
* `connectorOAuthRedirectUri` option, else that path under `OS_AUTH_URL`,
1189+
* else under `OS_BASE_URL`.
1190+
*
1191+
* Present on {@link IAutomationService.getConnectorConnection}'s answer
1192+
* whenever the server can derive it, whatever `status` says. Absent when
1193+
* the deployment has no base to derive it from — a connect is then refused
1194+
* `503` — and ⛔ never guessed from the request's origin. Only an
1195+
* `authorization_code` instance has a connection, so no other grant ever
1196+
* carries it. Read-only: no connection request takes it. Declared ahead of
1197+
* the service that answers it: until the automation service sets it, no
1198+
* answer carries it.
1199+
*/
1200+
redirectUri?: string;
11811201
}
11821202

11831203
/** What {@link IAutomationService.getConnectorConnection} and its revoke / begin siblings are asked about. */
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29574] `ConnectorConnectionState.redirectUri` — the deployment's derived
5+
* OAuth redirect URI, answered on the connection read so the Console's Connect
6+
* step can show it before an administrator registers a client with the vendor
7+
* (ADR-0143 D5, D2.1).
8+
*
9+
* Pinned at the type level, because a TypeScript interface member has no Zod
10+
* schema to assert against (the `automation-toggle-flow-caller` pin's form,
11+
* compiled by `check:test-typecheck` under `tsconfig.test.json`):
12+
*
13+
* 1. **One optional string.** Absent is a legal answer (no base to derive
14+
* from), so a state without it still compiles, and the key carries the URI
15+
* itself, never a structure around it.
16+
* 2. **Read-only.** No connection request carries it: the redirect URI is
17+
* never taken from a request, so the request type has no such key.
18+
* 3. **Additive.** Every key the state carried before keeps its type.
19+
* 4. **The TSDoc states when it is present.** Prose is unassertable except by
20+
* reading it, so the contract source is read and the doc block above the
21+
* key is required to name both absence conditions.
22+
*
23+
* ⛔ Not pinned here: that the connection door answers it. That is the server
24+
* half, `@objectstack/service-automation`'s `getConnectorConnection` (#29463).
25+
*/
26+
27+
import { readFileSync } from 'node:fs';
28+
import { fileURLToPath } from 'node:url';
29+
30+
import { describe, it, expect } from 'vitest';
31+
32+
import type { ConnectorConnectionRequest, ConnectorConnectionState } from './automation-service';
33+
34+
/** Type-level identity: true iff A and B are the same type. */
35+
type Eq< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
36+
/** Compile error when the argument is not `true`. */
37+
type Assert< T extends true > = T;
38+
39+
// Exported deliberately — an unread alias is TS6196, and a pin no program compiles is no pin at all.
40+
41+
/** The key is a string, and optional. */
42+
export type RedirectUriIsAnOptionalString = Assert< Eq< ConnectorConnectionState['redirectUri'], string | undefined > >;
43+
export type RedirectUriIsOptional = Assert< Eq< {} extends Pick< ConnectorConnectionState, 'redirectUri' > ? true : false, true > >;
44+
/** No request names it: the URI is fixed per deployment, never taken from a caller. */
45+
export type RequestCarriesNoRedirectUri = Assert< Eq< 'redirectUri' extends keyof ConnectorConnectionRequest ? true : false, false > >;
46+
/** Additive: the keys the state carried before are unchanged. */
47+
export type PriorKeysUnchanged = Assert< Eq<
48+
Omit< ConnectorConnectionState, 'redirectUri' >,
49+
{
50+
connector: string;
51+
organizationId: string;
52+
status: 'not_connected' | 'connected' | 'reconnect_required';
53+
statusReason?: string;
54+
scopes?: string;
55+
expiresAt?: string;
56+
connectedAt?: string;
57+
connectedBy?: string;
58+
client?: { source: 'installer'; clientId: string; hasSecret: boolean };
59+
}
60+
> >;
61+
62+
/** A state the server could derive no URI for still compiles. */
63+
export const withoutBase: ConnectorConnectionState = { connector: 'docusign', organizationId: 'org_1', status: 'not_connected' };
64+
// @ts-expect-error — the key carries the URI itself, not a structure around it.
65+
export const structured: ConnectorConnectionState = { ...withoutBase, redirectUri: { url: 'https://crm.example.com' } };
66+
67+
const SOURCE = readFileSync(fileURLToPath(new URL('./automation-service.ts', import.meta.url)), 'utf8');
68+
69+
/** The doc block directly above the key, read from the contract source. */
70+
function docAbove(key: string): string {
71+
const at = SOURCE.indexOf(` ${key}?: string;\n}`);
72+
expect(at, `${key} is the last member of ConnectorConnectionState`).toBeGreaterThan(0);
73+
const open = SOURCE.lastIndexOf('/**', at);
74+
return SOURCE.slice(open, at);
75+
}
76+
77+
describe('ConnectorConnectionState.redirectUri — the derived OAuth redirect URI', () => {
78+
it('a state carries the URI the server derived, and reads it back unchanged', () => {
79+
const uri = 'https://crm.example.com/api/v1/automation/connectors/oauth/callback';
80+
const state: ConnectorConnectionState = { ...withoutBase, redirectUri: uri };
81+
expect(state.redirectUri).toBe(uri);
82+
expect(withoutBase.redirectUri).toBeUndefined();
83+
});
84+
85+
it('the TSDoc names the callback path, the derivation chain and both absence conditions', () => {
86+
const doc = docAbove('redirectUri');
87+
expect(doc.length).toBeGreaterThan(0);
88+
expect(doc).toContain('/api/v1/automation/connectors/oauth/callback');
89+
for (const source of ['connectorOAuthRedirectUri', 'OS_AUTH_URL', 'OS_BASE_URL']) expect(doc).toContain(source);
90+
// Absent when there is no base — never a guess.
91+
expect(doc).toMatch(/Absent when\s+\*?\s*the deployment has no base/);
92+
expect(doc).toMatch(/never guessed/);
93+
// Absent for every grant other than `authorization_code`.
94+
expect(doc).toMatch(/no other grant ever\s+\*?\s*carries it/);
95+
});
96+
});

0 commit comments

Comments
 (0)