Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/29574-connector-redirect-uri.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/spec': patch
---

feat(spec): `ConnectorConnectionState` declares `redirectUri`, the deployment's derived OAuth redirect URI

Clause-②: yes (widening)
Metadata-protocol: none

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:

- **`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.
- **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.
- **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.
20 changes: 20 additions & 0 deletions packages/spec/src/contracts/automation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1178,6 +1178,26 @@ export interface ConnectorConnectionState {
clientId: string;
hasSecret: boolean;
};
/**
* ADR-0143 D5 — the redirect URI this deployment's connect names: the
* absolute URL of `GET /api/v1/automation/connectors/oauth/callback` that
* the administrator registers with the vendor's OAuth client, answered so
* the Connect step can show it before any client is entered (ADR-0143 D2.1,
* `clientSource: 'installer'`). Derived by the server and 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`.
*
* Present on {@link IAutomationService.getConnectorConnection}'s answer
* whenever the server can derive it, whatever `status` says. 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 ever
* carries it. Read-only: no connection request takes it. Declared ahead of
* the service that answers it: until the automation service sets it, no
* answer carries it.
*/
redirectUri?: string;
}

/** What {@link IAutomationService.getConnectorConnection} and its revoke / begin siblings are asked about. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#29574] `ConnectorConnectionState.redirectUri` — the deployment's derived
* OAuth redirect URI, answered on the connection read so the Console's Connect
* step can show it before an administrator registers a client with the vendor
* (ADR-0143 D5, D2.1).
*
* Pinned at the type level, because a TypeScript interface member has no Zod
* schema to assert against (the `automation-toggle-flow-caller` pin's form,
* compiled by `check:test-typecheck` under `tsconfig.test.json`):
*
* 1. **One optional string.** Absent is a legal answer (no base to derive
* from), so a state without it still compiles, and the key carries the URI
* itself, never a structure around it.
* 2. **Read-only.** No connection request carries it: the redirect URI is
* never taken from a request, so the request type has no such key.
* 3. **Additive.** Every key the state carried before keeps its type.
* 4. **The TSDoc states when it is present.** Prose is unassertable except by
* reading it, so the contract source is read and the doc block above the
* key is required to name both absence conditions.
*
* ⛔ Not pinned here: that the connection door answers it. That is the server
* half, `@objectstack/service-automation`'s `getConnectorConnection` (#29463).
*/

import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

import { describe, it, expect } from 'vitest';

import type { ConnectorConnectionRequest, ConnectorConnectionState } from './automation-service';

/** Type-level identity: true iff A and B are the same type. */
type Eq< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
/** Compile error when the argument is not `true`. */
type Assert< T extends true > = T;

// Exported deliberately — an unread alias is TS6196, and a pin no program compiles is no pin at all.

/** The key is a string, and optional. */
export type RedirectUriIsAnOptionalString = Assert< Eq< ConnectorConnectionState['redirectUri'], string | undefined > >;
export type RedirectUriIsOptional = Assert< Eq< {} extends Pick< ConnectorConnectionState, 'redirectUri' > ? true : false, true > >;
/** No request names it: the URI is fixed per deployment, never taken from a caller. */
export type RequestCarriesNoRedirectUri = Assert< Eq< 'redirectUri' extends keyof ConnectorConnectionRequest ? true : false, false > >;
/** Additive: the keys the state carried before are unchanged. */
export type PriorKeysUnchanged = Assert< Eq<
Omit< ConnectorConnectionState, 'redirectUri' >,
{
connector: string;
organizationId: string;
status: 'not_connected' | 'connected' | 'reconnect_required';
statusReason?: string;
scopes?: string;
expiresAt?: string;
connectedAt?: string;
connectedBy?: string;
client?: { source: 'installer'; clientId: string; hasSecret: boolean };
}
> >;

/** A state the server could derive no URI for still compiles. */
export const withoutBase: ConnectorConnectionState = { connector: 'docusign', organizationId: 'org_1', status: 'not_connected' };
// @ts-expect-error — the key carries the URI itself, not a structure around it.
export const structured: ConnectorConnectionState = { ...withoutBase, redirectUri: { url: 'https://crm.example.com' } };

const SOURCE = readFileSync(fileURLToPath(new URL('./automation-service.ts', import.meta.url)), 'utf8');

/** The doc block directly above the key, read from the contract source. */
function docAbove(key: string): string {
const at = SOURCE.indexOf(` ${key}?: string;\n}`);
expect(at, `${key} is the last member of ConnectorConnectionState`).toBeGreaterThan(0);
const open = SOURCE.lastIndexOf('/**', at);
return SOURCE.slice(open, at);
}

describe('ConnectorConnectionState.redirectUri — the derived OAuth redirect URI', () => {
it('a state carries the URI the server derived, and reads it back unchanged', () => {
const uri = 'https://crm.example.com/api/v1/automation/connectors/oauth/callback';
const state: ConnectorConnectionState = { ...withoutBase, redirectUri: uri };
expect(state.redirectUri).toBe(uri);
expect(withoutBase.redirectUri).toBeUndefined();
});

it('the TSDoc names the callback path, the derivation chain and both absence conditions', () => {
const doc = docAbove('redirectUri');
expect(doc.length).toBeGreaterThan(0);
expect(doc).toContain('/api/v1/automation/connectors/oauth/callback');
for (const source of ['connectorOAuthRedirectUri', 'OS_AUTH_URL', 'OS_BASE_URL']) expect(doc).toContain(source);
// Absent when there is no base — never a guess.
expect(doc).toMatch(/Absent when\s+\*?\s*the deployment has no base/);
expect(doc).toMatch(/never guessed/);
// Absent for every grant other than `authorization_code`.
expect(doc).toMatch(/no other grant ever\s+\*?\s*carries it/);
});
});
Loading