Skip to content

Commit d6a6075

Browse files
drewstonedrewstone
andauthored
feat: TangleAppsClient — self-service brokered hub-exec client (#73)
Adds the self-service external-apps client to the integrations/hub SDK, next to TangleIntegrationsClient: register an app (registerApp), mint short-lived sk-tan-broker- tokens unattended against a durable consented grant (mintBrokerToken), exchange the one-time consent code (exchangeAuthCode), list/revoke. The operator-allowlist-free (no TRUSTED_APPS) path any external developer uses to integrate the hub. Mirrors TangleIntegrationsClient (endpoint + fetchImpl seam, createTangle*Client factory, IntegrationRuntimeError surface; BROKER_DISABLED → passthrough_disabled; flat OAuth token endpoint handled). 5 tests; typecheck + build clean. This is the canonical home — supersedes the draft in agent-runtime (PR closed). Co-authored-by: drewstone <hello@webb.tools>
1 parent caab08b commit d6a6075

3 files changed

Lines changed: 313 additions & 0 deletions

File tree

src/apps.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { IntegrationRuntimeError, normalizeIntegrationError } from './errors.js'
2+
3+
/**
4+
* Self-service external-apps client — the brokered hub-exec path.
5+
*
6+
* The operator-allowlist-free way to integrate the Tangle hub (no `TRUSTED_APPS`
7+
* entry): a product registers itself ONCE (`registerApp` → client_id/secret),
8+
* the end user consents ONCE per connection from their Tangle session (a browser
9+
* step — not done here), and the product then mints short-lived
10+
* `sk-tan-broker-…` tokens unattended (`mintBrokerToken`) for each
11+
* `/v1/hub/exec` call. The durable grant means only the first consent needs a
12+
* user; everything after is app-credential-only.
13+
*
14+
* Sits alongside {@link TangleIntegrationsClient} in the integrations/hub SDK —
15+
* same `endpoint` + `fetchImpl` shape and the same `IntegrationRuntimeError`
16+
* surface. Endpoints (platform API, e.g. https://id.tangle.tools):
17+
* POST /v1/apps register (owner bearer)
18+
* GET /v1/apps list (owner bearer)
19+
* POST /v1/apps/:appId/revoke revoke (owner bearer)
20+
* POST /v1/apps/grants/:grantId/mint-broker-token durable re-mint (app creds)
21+
* POST /v1/apps/oauth/token authorization_code → token
22+
*/
23+
24+
export interface TangleAppsClientOptions {
25+
/** Platform base URL (e.g. https://id.tangle.tools). */
26+
endpoint: string
27+
/** Test seam. Defaults to global `fetch`. */
28+
fetchImpl?: typeof fetch
29+
}
30+
31+
export interface RegisterAppInput {
32+
name: string
33+
redirectUris: string[]
34+
allowedScopes: string[]
35+
homepageUrl?: string
36+
}
37+
38+
export interface AppSummary {
39+
id: string
40+
clientId: string
41+
name: string
42+
redirectUris: string[]
43+
allowedScopes: string[]
44+
homepageUrl?: string
45+
createdAt?: string
46+
}
47+
48+
export interface RegisteredApp extends AppSummary {
49+
/** Shown ONCE at registration — persist it as a secret immediately. */
50+
clientSecret: string
51+
}
52+
53+
export interface BrokerToken {
54+
/** The `sk-tan-broker-…` bearer for a single `/v1/hub/exec` call. */
55+
accessToken: string
56+
expiresIn: number
57+
scope: string
58+
connectionId?: string
59+
}
60+
61+
interface PlatformEnvelope<T> {
62+
success?: boolean
63+
data?: T
64+
error?: { code?: string; message?: string } | string
65+
}
66+
67+
interface TokenResponse {
68+
access_token: string
69+
expires_in: number
70+
scope: string
71+
connection_id?: string
72+
}
73+
74+
export class TangleAppsClient {
75+
private readonly endpoint: string
76+
private readonly fetchImpl: typeof fetch
77+
78+
constructor(options: TangleAppsClientOptions) {
79+
this.endpoint = options.endpoint.replace(/\/$/, '')
80+
this.fetchImpl = options.fetchImpl ?? fetch
81+
}
82+
83+
/**
84+
* Register a product as an app (ONE-TIME, with the owner's bearer — a Tangle
85+
* session or `sk-tan-*` key). Returns the client_id + the once-shown
86+
* client_secret; persist the secret immediately (never retrievable again).
87+
*/
88+
async registerApp(input: RegisterAppInput, ownerBearer: string): Promise<RegisteredApp> {
89+
const data = await this.request<{ app: AppSummary; clientSecret: string }>(
90+
'POST',
91+
'/v1/apps',
92+
input,
93+
ownerBearer,
94+
)
95+
return { ...data.app, clientSecret: data.clientSecret }
96+
}
97+
98+
/** List the caller's registered apps (no secrets). */
99+
async listApps(ownerBearer: string): Promise<AppSummary[]> {
100+
const data = await this.request<{ apps: AppSummary[] }>('GET', '/v1/apps', undefined, ownerBearer)
101+
return data.apps ?? []
102+
}
103+
104+
/** Revoke an app and cascade-kill its grants + tokens. */
105+
revokeApp(appId: string, ownerBearer: string): Promise<{ revoked: boolean }> {
106+
return this.request('POST', `/v1/apps/${encodeURIComponent(appId)}/revoke`, {}, ownerBearer)
107+
}
108+
109+
/**
110+
* Durable re-mint: mint a fresh single-use `sk-tan-broker-` token against an
111+
* existing consented grant using ONLY the app credentials — no user session,
112+
* no `agc_` code. The runtime path: one call per `/v1/hub/exec`.
113+
*/
114+
async mintBrokerToken(input: {
115+
clientId: string
116+
clientSecret: string
117+
grantId: string
118+
ttlSeconds?: number
119+
}): Promise<BrokerToken> {
120+
const data = await this.request<TokenResponse>(
121+
'POST',
122+
`/v1/apps/grants/${encodeURIComponent(input.grantId)}/mint-broker-token`,
123+
{
124+
client_id: input.clientId,
125+
client_secret: input.clientSecret,
126+
grant_id: input.grantId,
127+
...(input.ttlSeconds ? { ttl_seconds: input.ttlSeconds } : {}),
128+
},
129+
)
130+
return toBrokerToken(data)
131+
}
132+
133+
/**
134+
* Exchange an `agc_` authorization code (from the user's one-time consent)
135+
* for the first broker token + the durable grant. Use on the consent
136+
* callback; afterward `mintBrokerToken` is enough.
137+
*/
138+
async exchangeAuthCode(input: {
139+
clientId: string
140+
clientSecret: string
141+
code: string
142+
redirectUri: string
143+
connectionId?: string
144+
}): Promise<BrokerToken> {
145+
const data = await this.request<TokenResponse>('POST', '/v1/apps/oauth/token', {
146+
grant_type: 'authorization_code',
147+
client_id: input.clientId,
148+
client_secret: input.clientSecret,
149+
code: input.code,
150+
redirect_uri: input.redirectUri,
151+
...(input.connectionId ? { connection_id: input.connectionId } : {}),
152+
})
153+
return toBrokerToken(data)
154+
}
155+
156+
private async request<T>(
157+
method: 'GET' | 'POST' | 'DELETE',
158+
path: string,
159+
body?: unknown,
160+
bearer?: string,
161+
): Promise<T> {
162+
try {
163+
const headers: Record<string, string> = { accept: 'application/json' }
164+
if (bearer) headers.authorization = `Bearer ${bearer}`
165+
if (body !== undefined) headers['content-type'] = 'application/json'
166+
167+
const response = await this.fetchImpl(`${this.endpoint}${path}`, {
168+
method,
169+
headers,
170+
body: body !== undefined ? JSON.stringify(body) : undefined,
171+
})
172+
const json = (await response.json().catch(() => undefined)) as PlatformEnvelope<T> | undefined
173+
174+
if (!response.ok || (json && json.success === false)) {
175+
const platformCode = json?.error && typeof json.error === 'object' ? json.error.code : undefined
176+
const message =
177+
(json?.error && typeof json.error === 'object' && json.error.message) ||
178+
(typeof json?.error === 'string' ? json.error : `Tangle apps request failed (HTTP ${response.status})`)
179+
throw new IntegrationRuntimeError({
180+
code: platformCode === 'BROKER_DISABLED' ? 'passthrough_disabled' : 'unknown',
181+
message,
182+
status: response.status,
183+
metadata: { platformCode, path },
184+
})
185+
}
186+
// The /v1/apps surface wraps in { success, data }; /v1/apps/oauth/token is flat.
187+
return ((json && 'data' in json ? json.data : json) ?? ({} as T)) as T
188+
} catch (error) {
189+
if (error instanceof IntegrationRuntimeError) throw error
190+
const normalized = normalizeIntegrationError(error)
191+
throw new IntegrationRuntimeError({
192+
code: normalized.code,
193+
message: normalized.message,
194+
metadata: { path, userAction: normalized.userAction },
195+
})
196+
}
197+
}
198+
}
199+
200+
export function createTangleAppsClient(options: TangleAppsClientOptions): TangleAppsClient {
201+
return new TangleAppsClient(options)
202+
}
203+
204+
function toBrokerToken(data: TokenResponse): BrokerToken {
205+
return {
206+
accessToken: data.access_token,
207+
expiresIn: data.expires_in,
208+
scope: data.scope,
209+
connectionId: data.connection_id,
210+
}
211+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export * from './audit.js'
3939
export * from './approval.js'
4040
export * from './actions.js'
4141
export * from './bridge.js'
42+
export * from './apps.js'
4243
export * from './client.js'
4344
export * from './consumer.js'
4445
export * from './consent.js'

tests/apps-client.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { TangleAppsClient, createTangleAppsClient } from '../src/apps'
3+
import { IntegrationRuntimeError } from '../src/errors'
4+
5+
const ENDPOINT = 'https://id.tangle.tools'
6+
7+
function jsonResponse(body: unknown, status = 200): Response {
8+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
9+
}
10+
11+
function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
12+
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) =>
13+
handler(typeof input === 'string' ? input : input.toString(), init),
14+
) as unknown as typeof fetch
15+
}
16+
17+
describe('TangleAppsClient', () => {
18+
it('registerApp posts to /v1/apps with the owner bearer and returns the once-shown secret', async () => {
19+
let captured: { url: string; init?: RequestInit } | undefined
20+
const client = createTangleAppsClient({
21+
endpoint: ENDPOINT,
22+
fetchImpl: mockFetch((url, init) => {
23+
captured = { url, init }
24+
return jsonResponse({
25+
success: true,
26+
data: {
27+
app: { id: 'app_1', clientId: 'appcid_x', name: 'insurance-agent', redirectUris: ['https://insurance.tangle.tools/cb'], allowedScopes: ['gmail.messages_read'] },
28+
clientSecret: 'appcs_secret',
29+
},
30+
})
31+
}),
32+
})
33+
const app = await client.registerApp(
34+
{ name: 'insurance-agent', redirectUris: ['https://insurance.tangle.tools/cb'], allowedScopes: ['gmail.messages_read'] },
35+
'sk-tan-owner',
36+
)
37+
expect(captured?.url).toBe(`${ENDPOINT}/v1/apps`)
38+
expect(new Headers(captured?.init?.headers).get('authorization')).toBe('Bearer sk-tan-owner')
39+
expect(app.clientId).toBe('appcid_x')
40+
expect(app.clientSecret).toBe('appcs_secret')
41+
})
42+
43+
it('mintBrokerToken uses app credentials (no user bearer) and returns a sk-tan-broker- token', async () => {
44+
let captured: { url: string; init?: RequestInit } | undefined
45+
const client = new TangleAppsClient({
46+
endpoint: ENDPOINT,
47+
fetchImpl: mockFetch((url, init) => {
48+
captured = { url, init }
49+
return jsonResponse({ success: true, data: { access_token: 'sk-tan-broker-abc', expires_in: 900, scope: 'gmail.messages_read', connection_id: 'conn_1' } })
50+
}),
51+
})
52+
const tok = await client.mintBrokerToken({ clientId: 'appcid_x', clientSecret: 'appcs_secret', grantId: 'grant_42', ttlSeconds: 300 })
53+
expect(captured?.url).toBe(`${ENDPOINT}/v1/apps/grants/grant_42/mint-broker-token`)
54+
expect(new Headers(captured?.init?.headers).get('authorization')).toBeNull()
55+
expect(JSON.parse(String(captured?.init?.body))).toMatchObject({ client_id: 'appcid_x', client_secret: 'appcs_secret', grant_id: 'grant_42', ttl_seconds: 300 })
56+
expect(tok.accessToken).toBe('sk-tan-broker-abc')
57+
expect(tok.accessToken.startsWith('sk-tan-broker-')).toBe(true)
58+
expect(tok.expiresIn).toBe(900)
59+
expect(tok.connectionId).toBe('conn_1')
60+
})
61+
62+
it('exchangeAuthCode posts the agc_ code on the flat OAuth token endpoint', async () => {
63+
let body: Record<string, unknown> | undefined
64+
const client = new TangleAppsClient({
65+
endpoint: ENDPOINT,
66+
fetchImpl: mockFetch((_url, init) => {
67+
body = JSON.parse(String(init?.body))
68+
return jsonResponse({ access_token: 'sk-tan-broker-first', token_type: 'Bearer', expires_in: 3600, scope: 'gmail.messages_read' })
69+
}),
70+
})
71+
const tok = await client.exchangeAuthCode({ clientId: 'appcid_x', clientSecret: 'appcs_secret', code: 'agc_code', redirectUri: 'https://insurance.tangle.tools/cb' })
72+
expect(body).toMatchObject({ grant_type: 'authorization_code', code: 'agc_code', client_id: 'appcid_x' })
73+
expect(tok.accessToken).toBe('sk-tan-broker-first')
74+
expect(tok.expiresIn).toBe(3600)
75+
})
76+
77+
it('maps the BROKER_DISABLED platform error to an IntegrationRuntimeError', async () => {
78+
const client = new TangleAppsClient({
79+
endpoint: ENDPOINT,
80+
fetchImpl: mockFetch(() => jsonResponse({ success: false, error: { message: 'broker disabled', code: 'BROKER_DISABLED' } }, 503)),
81+
})
82+
const err = await client.mintBrokerToken({ clientId: 'a', clientSecret: 'b', grantId: 'g' }).catch((e: unknown) => e)
83+
expect(err).toBeInstanceOf(IntegrationRuntimeError)
84+
expect((err as IntegrationRuntimeError).code).toBe('passthrough_disabled')
85+
expect((err as IntegrationRuntimeError).status).toBe(503)
86+
expect((err as IntegrationRuntimeError).metadata).toMatchObject({ platformCode: 'BROKER_DISABLED' })
87+
})
88+
89+
it('normalizes a trailing slash in endpoint', async () => {
90+
let url = ''
91+
const client = new TangleAppsClient({
92+
endpoint: `${ENDPOINT}/`,
93+
fetchImpl: mockFetch((u) => {
94+
url = u
95+
return jsonResponse({ data: { access_token: 'sk-tan-broker-z', expires_in: 60, scope: 's' } })
96+
}),
97+
})
98+
await client.mintBrokerToken({ clientId: 'a', clientSecret: 'b', grantId: 'g' })
99+
expect(url).toBe(`${ENDPOINT}/v1/apps/grants/g/mint-broker-token`)
100+
})
101+
})

0 commit comments

Comments
 (0)