Skip to content

Commit cce5fce

Browse files
feat(0.27.0): id.tangle.tools auth + connect-flow + middleware (#46)
Adds the identity substrate every Tangle product (legal, tax, gtm, creative, agent-builder, sandbox, evals, ...) sits on. Three pieces, one PR: - `connectors/adapters/tangle-id.ts` — `tangleIdentity()` adapter exposing verify_token / get_user / list_workspaces / switch_workspace / revoke_session against id.tangle.tools. Wire-protocol-compatible with the platform's `cross-site` + Better Auth routes (matches tcloud + sandbox PlatformClient). Service tokens are explicitly refused as user identity to mirror the platform's resolveServiceIdentity guard. - `connect/` — cross-product connect flow (`startConnectFlow`, `finishConnectFlow`, `revokeConnectFlow`, `InMemoryConnectStateStore`). Mints `sk-tan-*` keys bound to the calling user. Stateless; caller persists the key in its own encrypted credentials store. - `middleware/` — framework-agnostic `requireTangleAuth` + Hono and Express wrappers. Pulls Bearer / better-auth.session_token from a Request and resolves a typed `TangleAuthContext`. Platform unreachability becomes 503; bad-tokens become 401; service-token-refusal becomes 403. Discovery gains `filterDiscoveryByWorkspaceScopes` to gate the agent's tool registry against the calling workspace's effective scopes — wildcard scopes (`tangle:*`, `<connectorId>:*`) honored, fail-closed posture available via `denyByDefault`. 141 new tests (regression coverage for token-kind routing, service-token refusal, malformed/expired/unreachable paths, CSRF state TTL, scope filtering, hono + express middleware short-circuit envelopes). Co-authored-by: Drew Stone <drewstone329@gmail.com>
1 parent 473dc35 commit cce5fce

11 files changed

Lines changed: 1960 additions & 1 deletion

File tree

package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tangle-network/agent-integrations",
3-
"version": "0.26.0",
3+
"version": "0.27.0",
44
"description": "Vendor-neutral integration contracts and runtime helpers for sandbox and agent apps.",
55
"homepage": "https://github.com/tangle-network/agent-integrations#readme",
66
"repository": {
@@ -34,6 +34,16 @@
3434
"import": "./dist/connectors/adapters/index.js",
3535
"default": "./dist/connectors/adapters/index.js"
3636
},
37+
"./connect": {
38+
"types": "./dist/connect/index.d.ts",
39+
"import": "./dist/connect/index.js",
40+
"default": "./dist/connect/index.js"
41+
},
42+
"./middleware": {
43+
"types": "./dist/middleware/index.d.ts",
44+
"import": "./dist/middleware/index.js",
45+
"default": "./dist/middleware/index.js"
46+
},
3747
"./webhooks": {
3848
"types": "./dist/webhooks/index.d.ts",
3949
"import": "./dist/webhooks/index.js",

src/connect/index.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/**
2+
* @stable Cross-product connect flow.
3+
*
4+
* A product app (legal, tax, gtm, creative, agent-builder, sandbox, …) that
5+
* is already part of the Tangle trusted-app registry on id.tangle.tools
6+
* routes its users through this flow to obtain an `sk-tan-*` API key bound
7+
* to the calling user. The shape mirrors the platform's `/cross-site/*`
8+
* routes one-for-one so consumers can swap a bespoke fetch loop for these
9+
* helpers without changing the wire protocol.
10+
*
11+
* Three stages:
12+
*
13+
* 1. start({ appId, returnUrl, state }) → { authorizeUrl }
14+
* The product redirects the user to `authorizeUrl`. id.tangle.tools
15+
* checks the session cookie; if absent it punts to the login page
16+
* with a callback back to /cross-site/authorize.
17+
*
18+
* 2. callback({ code, app, state }) → { apiKey, user, workspaceId }
19+
* id.tangle.tools redirects back to the product's `returnUrl` with
20+
* `?code=…&app=…&state=…`. The product calls `finish()` with the
21+
* code; the helper POSTs /cross-site/exchange and returns the minted
22+
* key + identity. `state` is verified by the caller against its own
23+
* session (we never see it twice; CSRF is the caller's responsibility
24+
* per the platform contract — see `cross-site.ts` line 148).
25+
*
26+
* 3. revoke({ apiKey }) → void
27+
* Revoke the credential. Wraps `tangleIdentity().revokeSession`.
28+
*
29+
* Storage: this module is stateless. Persistence of the minted key (per
30+
* user, per workspace) is the caller's job — it goes in whatever
31+
* encrypted-credentials store the product already runs (sandbox uses Redis,
32+
* gtm uses Postgres, blueprints uses CF KV). The recipe is identical to
33+
* sandbox/api/src/lib/platform-client.ts — caller supplies a store, this
34+
* module hands back the raw key once and never persists it.
35+
*
36+
* Why not invent a new wire protocol: tcloud + sandbox already speak this
37+
* one against the live platform deployment. Diverging breaks the boundary
38+
* we maintain at the directive level ("DO NOT invent the wire protocol —
39+
* use what tcloud already does"). Every byte on the wire here matches a
40+
* test in `products/platform/api/tests/cross-site.test.ts`.
41+
*/
42+
43+
import {
44+
createTangleIdentityClient,
45+
DEFAULT_TANGLE_PLATFORM_URL,
46+
TangleIdentityUnreachableError,
47+
type TangleIdentityOptions,
48+
type TangleUserSummary,
49+
} from '../connectors/adapters/tangle-id.js'
50+
51+
export interface ConnectFlowOptions extends TangleIdentityOptions {
52+
/** Base URL of id.tangle.tools (defaults to {@link DEFAULT_TANGLE_PLATFORM_URL}). */
53+
baseUrl?: string
54+
}
55+
56+
export interface StartConnectInput {
57+
/** Trusted app id (registered on id.tangle.tools — `evals`, `sandbox`,
58+
* `agent-builder`, `tax-agent`, `legal-agent`, …). */
59+
appId: string
60+
/** Caller-generated CSRF nonce. The caller stashes it in its own
61+
* session/cookie store; on the callback it MUST be compared against
62+
* the `state` returned in the redirect. */
63+
state: string
64+
/** Optional exact-match override of the registered callback URI. When
65+
* omitted, the platform falls back to the app's first registered
66+
* redirectUri. When provided, MUST equal one of the registered entries
67+
* (origin + pathname) — otherwise the platform refuses the flow. */
68+
redirectUri?: string
69+
}
70+
71+
export interface StartConnectOutput {
72+
/** The URL to redirect the user's browser to. */
73+
authorizeUrl: string
74+
}
75+
76+
export interface FinishConnectInput {
77+
/** Auth code returned by id.tangle.tools on the callback redirect. */
78+
code: string
79+
/** Same `appId` passed to `start()`. */
80+
appId: string
81+
}
82+
83+
export interface FinishConnectOutput {
84+
/** Newly-minted `sk-tan-*` API key bound to the calling user. Returned
85+
* ONCE — caller is responsible for stashing it in the product's
86+
* encrypted credentials store. */
87+
apiKey: string
88+
/** Identity hydrated from the exchange response. */
89+
user: TangleUserSummary
90+
/** Initial balance the platform returns alongside the key. */
91+
balance: number
92+
}
93+
94+
/** Initiate a cross-product connect flow. Returns the URL the product
95+
* app should redirect the user's browser to. */
96+
export function startConnectFlow(
97+
opts: ConnectFlowOptions,
98+
input: StartConnectInput,
99+
): StartConnectOutput {
100+
if (!input.appId) {
101+
throw new TangleIdentityUnreachableError('connect/start: appId is required')
102+
}
103+
if (!input.state) {
104+
throw new TangleIdentityUnreachableError(
105+
'connect/start: state is required for CSRF protection (matches the platform contract)',
106+
)
107+
}
108+
const baseUrl = (opts.baseUrl ?? DEFAULT_TANGLE_PLATFORM_URL).replace(/\/+$/, '')
109+
const url = new URL(`${baseUrl}/cross-site/authorize`)
110+
url.searchParams.set('app', input.appId)
111+
url.searchParams.set('state', input.state)
112+
if (input.redirectUri) url.searchParams.set('redirect', input.redirectUri)
113+
return { authorizeUrl: url.toString() }
114+
}
115+
116+
/** Finish a cross-product connect flow. Calls /cross-site/exchange and
117+
* returns the minted API key + hydrated user identity. */
118+
export async function finishConnectFlow(
119+
opts: ConnectFlowOptions,
120+
input: FinishConnectInput,
121+
): Promise<FinishConnectOutput> {
122+
if (!input.code) {
123+
throw new TangleIdentityUnreachableError('connect/finish: code is required')
124+
}
125+
if (!input.appId) {
126+
throw new TangleIdentityUnreachableError('connect/finish: appId is required')
127+
}
128+
const baseUrl = (opts.baseUrl ?? DEFAULT_TANGLE_PLATFORM_URL).replace(/\/+$/, '')
129+
const fetchImpl = opts.fetchImpl ?? fetch
130+
const timeoutMs = opts.timeoutMs ?? 5_000
131+
let res: Response
132+
try {
133+
res = await fetchImpl(`${baseUrl}/cross-site/exchange`, {
134+
method: 'POST',
135+
headers: { 'content-type': 'application/json' },
136+
body: JSON.stringify({ code: input.code, app: input.appId }),
137+
signal: AbortSignal.timeout(timeoutMs),
138+
})
139+
} catch (err) {
140+
throw new TangleIdentityUnreachableError('connect/finish: exchange request failed', { cause: err })
141+
}
142+
if (res.status === 401) {
143+
throw new TangleIdentityUnreachableError(
144+
'connect/finish: exchange code rejected — replay, expired, or wrong app',
145+
{ status: 401 },
146+
)
147+
}
148+
if (!res.ok) {
149+
const detail = await res.text().catch(() => '')
150+
throw new TangleIdentityUnreachableError(
151+
`connect/finish: /cross-site/exchange returned ${res.status}: ${detail.slice(0, 200)}`,
152+
{ status: res.status },
153+
)
154+
}
155+
const body = (await res.json().catch(() => null)) as
156+
| {
157+
apiKey?: string
158+
user?: { id?: string; email?: string; name?: string | null; image?: string | null }
159+
balance?: number
160+
}
161+
| null
162+
if (!body || typeof body.apiKey !== 'string' || !body.user || typeof body.user.id !== 'string') {
163+
throw new TangleIdentityUnreachableError('connect/finish: exchange response had an invalid shape')
164+
}
165+
return {
166+
apiKey: body.apiKey,
167+
user: {
168+
id: body.user.id,
169+
...(typeof body.user.email === 'string' ? { email: body.user.email } : {}),
170+
...(body.user.name !== undefined ? { name: body.user.name } : {}),
171+
...(body.user.image !== undefined ? { image: body.user.image } : {}),
172+
},
173+
balance: typeof body.balance === 'number' && Number.isFinite(body.balance) ? body.balance : 0,
174+
}
175+
}
176+
177+
/** Revoke a minted API key. Idempotent — re-revoking a stale key is a no-op. */
178+
export async function revokeConnectFlow(
179+
opts: ConnectFlowOptions,
180+
input: { apiKey: string },
181+
): Promise<void> {
182+
if (!input.apiKey) {
183+
throw new TangleIdentityUnreachableError('connect/revoke: apiKey is required')
184+
}
185+
const client = createTangleIdentityClient(opts)
186+
await client.revokeSession(input.apiKey)
187+
}
188+
189+
/**
190+
* Convenience: build a tiny session manager keyed by `state` for products
191+
* that don't already have a CSRF store. NOT recommended for production —
192+
* use your existing session cookie / signed-state mechanism. Exposed for
193+
* tests and for quick prototyping. In-memory; not shared across workers.
194+
*/
195+
export class InMemoryConnectStateStore {
196+
private readonly entries = new Map<string, { appId: string; expiresAt: number }>()
197+
198+
put(state: string, value: { appId: string; ttlMs?: number }): void {
199+
this.entries.set(state, {
200+
appId: value.appId,
201+
expiresAt: Date.now() + (value.ttlMs ?? 10 * 60_000),
202+
})
203+
}
204+
205+
consume(state: string): { appId: string } | undefined {
206+
const entry = this.entries.get(state)
207+
this.entries.delete(state)
208+
if (!entry || entry.expiresAt <= Date.now()) return undefined
209+
return { appId: entry.appId }
210+
}
211+
212+
/** Test-only — drop pending state between unit-test runs. */
213+
clear(): void {
214+
this.entries.clear()
215+
}
216+
}

src/connectors/adapters/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,18 @@ export { gitlabConnector } from './gitlab.js'
4444
export { airtableConnector } from './airtable.js'
4545
export { asanaConnector } from './asana.js'
4646
export { salesforceConnector } from './salesforce.js'
47+
48+
export {
49+
tangleIdentity,
50+
createTangleIdentityClient,
51+
DEFAULT_TANGLE_PLATFORM_URL,
52+
TANGLE_API_KEY_PREFIX,
53+
TANGLE_SERVICE_TOKEN_PREFIX,
54+
TangleIdentityUnreachableError,
55+
type TangleIdentityClient,
56+
type TangleIdentityOptions,
57+
type TangleTokenVerifyFailure,
58+
type TangleTokenVerifyResult,
59+
type TangleUserSummary,
60+
type TangleWorkspaceSummary,
61+
} from './tangle-id.js'

0 commit comments

Comments
 (0)