|
| 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 | +} |
0 commit comments