Skip to content

Commit 1e842d4

Browse files
Merge pull request #19 from tangle-network/feat/user-tangle-execution-key
feat(runtime): user-scoped Tangle execution key + billing enforcement policy
2 parents 5d38feb + ef0b797 commit 1e842d4

3 files changed

Lines changed: 391 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tangle-network/agent-app",
3-
"version": "0.3.0",
3+
"version": "0.3.1",
44
"packageManager": "pnpm@10.33.4",
55
"description": "Application-shell framework for Tangle agent products: a bounded tool loop, the structured agent\u2192app tool side channel, integration-hub client, per-workspace billing, and crypto \u2014 composed over the Tangle agent substrate through typed seams.",
66
"keywords": [

src/runtime/model.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,214 @@ export interface TangleModelConfig {
1717
baseUrl: string
1818
}
1919

20+
export type TangleExecutionEnvironment = 'development' | 'staging' | 'production' | 'test'
21+
export type TangleExecutionKeySource = 'local-env' | 'user'
22+
export type TangleExecutionKeyErrorCode =
23+
| 'local_tangle_api_key_required'
24+
| 'tangle_account_not_connected'
25+
2026
export interface ResolveModelOptions {
2127
/** Env to read (defaults to process.env). */
2228
env?: Record<string, string | undefined>
2329
/** Router base URL default when `TANGLE_ROUTER_BASE_URL` is unset. */
2430
defaultRouterBaseUrl?: string
2531
}
2632

33+
export interface ResolveUserTangleExecutionKeyOptions {
34+
/** Deployment context. Only local development may fall back to env keys. */
35+
environment?: TangleExecutionEnvironment
36+
/** Env to read for the local-development fallback. */
37+
env?: Record<string, string | undefined>
38+
/** App-owned lookup for the caller's linked platform API key. */
39+
getUserApiKey: () => string | null | undefined | Promise<string | null | undefined>
40+
}
41+
42+
export interface ResolveUserTangleExecutionKeyForUserOptions<UserId = string> {
43+
userId: UserId
44+
environment?: TangleExecutionEnvironment
45+
env?: Record<string, string | undefined>
46+
getUserApiKey: (userId: UserId) => string | null | undefined | Promise<string | null | undefined>
47+
}
48+
49+
export interface ResolvedTangleExecutionKey {
50+
apiKey: string
51+
source: TangleExecutionKeySource
52+
}
53+
54+
export interface TangleExecutionKeyHttpError {
55+
status: number
56+
body: {
57+
error: string
58+
code: TangleExecutionKeyErrorCode
59+
}
60+
}
61+
62+
export interface CreateTangleRouterModelConfigOptions {
63+
apiKey: string
64+
model: string
65+
baseUrl?: string
66+
}
67+
68+
export interface TangleBillingEnforcementOptions {
69+
/** Env to read (defaults to process.env). */
70+
env?: Record<string, string | undefined>
71+
/**
72+
* Optional app-specific override flag, e.g. `GTM_BILLING_ENFORCEMENT`.
73+
* Defaults to the shared `TANGLE_BILLING_ENFORCEMENT`.
74+
*/
75+
enforcementEnvVar?: string
76+
}
77+
2778
export const DEFAULT_TANGLE_ROUTER_BASE_URL = 'https://router.tangle.tools/v1'
79+
export const DEFAULT_TANGLE_BILLING_ENFORCEMENT_ENV_VAR = 'TANGLE_BILLING_ENFORCEMENT'
2880

2981
function requireEnv(env: Record<string, string | undefined>, name: string): string {
3082
const value = env[name]?.trim()
3183
if (!value) throw new Error(`${name} is required`)
3284
return value
3385
}
3486

87+
function trimOrNull(value: string | null | undefined): string | null {
88+
const trimmed = value?.trim()
89+
return trimmed ? trimmed : null
90+
}
91+
92+
function isTangleExecutionKeyErrorCode(value: unknown): value is TangleExecutionKeyErrorCode {
93+
return value === 'local_tangle_api_key_required' || value === 'tangle_account_not_connected'
94+
}
95+
96+
export class TangleExecutionKeyError extends Error {
97+
readonly code: TangleExecutionKeyErrorCode
98+
readonly status: number
99+
100+
constructor(code: TangleExecutionKeyErrorCode, message: string, status: number) {
101+
super(message)
102+
this.name = 'TangleExecutionKeyError'
103+
this.code = code
104+
this.status = status
105+
}
106+
}
107+
108+
export function isTangleExecutionKeyError(error: unknown): error is TangleExecutionKeyError {
109+
return error instanceof TangleExecutionKeyError
110+
|| (
111+
typeof error === 'object'
112+
&& error !== null
113+
&& (error as { name?: unknown }).name === 'TangleExecutionKeyError'
114+
&& typeof (error as { message?: unknown }).message === 'string'
115+
&& isTangleExecutionKeyErrorCode((error as { code?: unknown }).code)
116+
&& typeof (error as { status?: unknown }).status === 'number'
117+
)
118+
}
119+
120+
export function resolveTangleExecutionEnvironment(
121+
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
122+
): TangleExecutionEnvironment {
123+
const raw = (env.APP_ENV ?? env.NODE_ENV ?? '').trim().toLowerCase()
124+
if (raw === 'development' || raw === 'dev' || raw === 'local') return 'development'
125+
if (raw === 'staging') return 'staging'
126+
if (raw === 'test') return 'test'
127+
return 'production'
128+
}
129+
130+
/**
131+
* Shared policy for agent products that bill through the Tangle Platform.
132+
*
133+
* Local development defaults billing enforcement off so apps can use a local
134+
* `TANGLE_API_KEY` without requiring a browser-linked platform account. Any
135+
* non-development environment defaults enforcement on. Apps may pass their own
136+
* override flag (`FOO_BILLING_ENFORCEMENT`) while new apps can use the shared
137+
* `TANGLE_BILLING_ENFORCEMENT`.
138+
*/
139+
export function isTangleBillingEnforcementDisabled(
140+
opts: TangleBillingEnforcementOptions = {},
141+
): boolean {
142+
const env = opts.env ?? (process.env as Record<string, string | undefined>)
143+
const enforcementEnvVar = opts.enforcementEnvVar ?? DEFAULT_TANGLE_BILLING_ENFORCEMENT_ENV_VAR
144+
const override = env[enforcementEnvVar]?.trim().toLowerCase()
145+
146+
if (override === 'disabled') return true
147+
if (override === 'enabled') return false
148+
149+
return resolveTangleExecutionEnvironment(env) === 'development'
150+
}
151+
152+
export function tangleExecutionKeyHttpError(error: unknown): TangleExecutionKeyHttpError | null {
153+
if (!isTangleExecutionKeyError(error)) return null
154+
return {
155+
status: error.status,
156+
body: {
157+
error: error.message,
158+
code: error.code,
159+
},
160+
}
161+
}
162+
163+
/**
164+
* Resolve the user-facing Tangle API key for model execution.
165+
*
166+
* Local development may use a server env key so apps remain easy to run.
167+
* Deployed contexts must use the caller's linked platform key; this keeps
168+
* model execution, billing, and account ownership aligned across products.
169+
*/
170+
export async function resolveUserTangleExecutionKey(
171+
opts: ResolveUserTangleExecutionKeyOptions,
172+
): Promise<ResolvedTangleExecutionKey> {
173+
const env = opts.env ?? (process.env as Record<string, string | undefined>)
174+
const environment = opts.environment ?? resolveTangleExecutionEnvironment(env)
175+
176+
if (environment === 'development') {
177+
const apiKey = trimOrNull(env.TANGLE_API_KEY)
178+
if (apiKey) return { apiKey, source: 'local-env' }
179+
}
180+
181+
const apiKey = trimOrNull(await opts.getUserApiKey())
182+
if (apiKey) return { apiKey, source: 'user' }
183+
184+
if (environment === 'development') {
185+
throw new TangleExecutionKeyError(
186+
'local_tangle_api_key_required',
187+
'TANGLE_API_KEY or a linked Tangle account is required for local Tangle model execution.',
188+
503,
189+
)
190+
}
191+
192+
throw new TangleExecutionKeyError(
193+
'tangle_account_not_connected',
194+
'Connect your Tangle account before invoking this agent.',
195+
401,
196+
)
197+
}
198+
199+
export async function resolveUserTangleExecutionKeyForUser<UserId = string>(
200+
opts: ResolveUserTangleExecutionKeyForUserOptions<UserId>,
201+
): Promise<ResolvedTangleExecutionKey> {
202+
return resolveUserTangleExecutionKey({
203+
environment: opts.environment,
204+
env: opts.env,
205+
getUserApiKey: () => opts.getUserApiKey(opts.userId),
206+
})
207+
}
208+
209+
/**
210+
* Build an OpenAI-compatible Tangle Router model config from an already
211+
* resolved execution key. This intentionally does not read TANGLE_API_KEY.
212+
*/
213+
export function createTangleRouterModelConfig(
214+
opts: CreateTangleRouterModelConfigOptions,
215+
): TangleModelConfig {
216+
const apiKey = opts.apiKey.trim()
217+
if (!apiKey) throw new Error('apiKey is required')
218+
const model = opts.model.trim()
219+
if (!model) throw new Error('model is required')
220+
return {
221+
provider: 'openai-compat',
222+
model,
223+
apiKey,
224+
baseUrl: (opts.baseUrl?.trim() || DEFAULT_TANGLE_ROUTER_BASE_URL).replace(/\/+$/, ''),
225+
}
226+
}
227+
35228
/**
36229
* Resolve the model config from env. DEFAULT path (`MODEL_PROVIDER` unset or
37230
* `openai-compat`/`tangle-router`/`tcloud`): the Tangle Router, authenticated

0 commit comments

Comments
 (0)