@tealbrick/portal 0.3.0-rc.4 → 0.3.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/native-bridge.d.ts +2 -0
- package/dist/native-bridge.js +15 -1
- package/dist/runtime.d.ts +13 -0
- package/dist/runtime.js +63 -6
- package/package.json +1 -1
package/dist/native-bridge.d.ts
CHANGED
|
@@ -113,6 +113,8 @@ export interface NativeBridgeOptions<A extends NativeAccess = NativeAccess> {
|
|
|
113
113
|
maxActiveTurns?: number;
|
|
114
114
|
/** Structured operational log. Receives ids, names and status only. */
|
|
115
115
|
log?: (event: Record<string, unknown>) => void;
|
|
116
|
+
/** Owner-only snapshot of this agent's Portal-granted apps (no credentials), served at /tealbrick/v1/capabilities. */
|
|
117
|
+
appCapabilities?: () => Promise<unknown>;
|
|
116
118
|
}
|
|
117
119
|
type Event = {
|
|
118
120
|
type: string;
|
package/dist/native-bridge.js
CHANGED
|
@@ -130,7 +130,21 @@ export async function createNativeBridge(options) {
|
|
|
130
130
|
return;
|
|
131
131
|
}
|
|
132
132
|
if (req.method === 'GET' && url.pathname === '/tealbrick/v1/info') {
|
|
133
|
-
json(res, 200, { harness: info.harness, protocol: info.protocol, name: options.name, ...(info.model ? { model: info.model } : {}), capabilities: info.capabilities, unsupported: info.unsupported });
|
|
133
|
+
json(res, 200, { harness: info.harness, protocol: info.protocol, name: options.name, ...(info.model ? { model: info.model } : {}), capabilities: [...info.capabilities, ...(options.appCapabilities ? ['app-capabilities'] : [])], unsupported: info.unsupported });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (req.method === 'GET' && url.pathname === '/tealbrick/v1/capabilities') {
|
|
137
|
+
if (!options.appCapabilities) {
|
|
138
|
+
json(res, 404, { error: 'app_capabilities_unsupported' });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
json(res, 200, await options.appCapabilities());
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
const m = e?.message;
|
|
146
|
+
json(res, 503, { error: typeof m === 'string' && /^[a-z][a-z0-9_]{0,80}$/.test(m) ? m : 'app_capabilities_unavailable' });
|
|
147
|
+
}
|
|
134
148
|
return;
|
|
135
149
|
}
|
|
136
150
|
const match = /^\/eve\/v1\/session(?:\/([a-zA-Z0-9-]+))?(?:\/(stream|cancel|access))?$/.exec(url.pathname);
|
package/dist/runtime.d.ts
CHANGED
|
@@ -34,6 +34,13 @@ export interface RuntimeOptions extends RuntimeIdentity {
|
|
|
34
34
|
fetch?: typeof fetch;
|
|
35
35
|
keys?: JWTVerifyGetKey;
|
|
36
36
|
now?: () => number;
|
|
37
|
+
/**
|
|
38
|
+
* Portal-provisioned Knowledge (no local binding for its bindingRef) is reached with a short-lived,
|
|
39
|
+
* instance-bound grant that Portal mints from the saved canvas wire and Knowledge validates live.
|
|
40
|
+
* Default true. A local binding whose credentialRef is TEALBRICK_PORTAL_GRANT uses a grant with
|
|
41
|
+
* its own endpoint. A present-but-mismatched local binding never falls back to a grant.
|
|
42
|
+
*/
|
|
43
|
+
portalGrants?: boolean;
|
|
37
44
|
audit?: (event: {
|
|
38
45
|
connectionId: string;
|
|
39
46
|
registrationId: string;
|
|
@@ -51,6 +58,8 @@ export declare class RuntimeConnector {
|
|
|
51
58
|
private readonly issuer;
|
|
52
59
|
private readonly fetcher;
|
|
53
60
|
private readonly keys;
|
|
61
|
+
/** Grant-mode apps whose Portal grant carried an endpoint (Portal-provisioned and reachable). */
|
|
62
|
+
private readonly reachableByGrant;
|
|
54
63
|
private cached?;
|
|
55
64
|
private refreshing?;
|
|
56
65
|
constructor(options: RuntimeOptions);
|
|
@@ -58,6 +67,10 @@ export declare class RuntimeConnector {
|
|
|
58
67
|
private portal;
|
|
59
68
|
private refresh;
|
|
60
69
|
private matchBinding;
|
|
70
|
+
/** Grant mode: no local binding at all for this app (Portal-provisioned), or an explicit grant binding. */
|
|
71
|
+
private grantMode;
|
|
72
|
+
private grantKey;
|
|
73
|
+
private portalGrant;
|
|
61
74
|
private current;
|
|
62
75
|
capabilities(force?: boolean): Promise<{
|
|
63
76
|
revision: string;
|
package/dist/runtime.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';
|
|
2
3
|
import { nativeMemoryOperations, nativeRequiredActions, nativeRequest } from './knowledge-native.js';
|
|
3
4
|
const actions = { list_collections: 'read', create_collection: 'create', create_document: 'create', get_document: 'read', update_document: 'update', delete_document: 'delete', search: 'read', memory_recall: 'read', memory_context: 'read', memory_entities: 'read' };
|
|
@@ -44,12 +45,19 @@ function validateApps(value) {
|
|
|
44
45
|
}
|
|
45
46
|
return value;
|
|
46
47
|
}
|
|
48
|
+
const PORTAL_GRANT_REF = 'TEALBRICK_PORTAL_GRANT';
|
|
49
|
+
const GRANT_PATTERN = /^tbkg_[A-Za-z0-9_-]{43}$/;
|
|
50
|
+
// Shared across connector instances in this process: native serve builds a fresh connector per
|
|
51
|
+
// invocation, and a grant is only a bearer that Knowledge re-validates live against Portal.
|
|
52
|
+
const sharedGrants = new Map();
|
|
47
53
|
/** Customer-owned execution boundary. Portal receives configuration acknowledgments only. */
|
|
48
54
|
export class RuntimeConnector {
|
|
49
55
|
options;
|
|
50
56
|
issuer;
|
|
51
57
|
fetcher;
|
|
52
58
|
keys;
|
|
59
|
+
/** Grant-mode apps whose Portal grant carried an endpoint (Portal-provisioned and reachable). */
|
|
60
|
+
reachableByGrant = new Set();
|
|
53
61
|
cached;
|
|
54
62
|
refreshing;
|
|
55
63
|
constructor(options) {
|
|
@@ -88,7 +96,15 @@ export class RuntimeConnector {
|
|
|
88
96
|
throw Error('runtime_invalid_config');
|
|
89
97
|
const apps = validateApps(payload.apps);
|
|
90
98
|
this.cached = { apps, revision: payload.revision, expires: payload.exp * 1000, checked: this.now() };
|
|
91
|
-
|
|
99
|
+
// A grant-mode app without a local binding is effective only if Portal hands back an endpoint.
|
|
100
|
+
this.reachableByGrant.clear();
|
|
101
|
+
for (const a of apps)
|
|
102
|
+
if (!this.options.bindings[a.bindingRef] && this.grantMode(a)) {
|
|
103
|
+
const g = await this.portalGrant(a).catch(() => undefined);
|
|
104
|
+
if (g?.endpoint)
|
|
105
|
+
this.reachableByGrant.add(a.registrationId);
|
|
106
|
+
}
|
|
107
|
+
const missing = apps.some(a => !this.matchBinding(a) && !(this.grantMode(a) && (this.options.bindings[a.bindingRef] || this.reachableByGrant.has(a.registrationId))));
|
|
92
108
|
// An acknowledgement failure must not extend or replace the signed authorization lease.
|
|
93
109
|
await this.portal('/api/runtime/ack', { revision: payload.revision, status: missing ? 'unavailable' : 'effective', ...(missing ? { errorCode: 'binding_missing' } : {}) }).catch(() => undefined);
|
|
94
110
|
}
|
|
@@ -98,6 +114,40 @@ export class RuntimeConnector {
|
|
|
98
114
|
}
|
|
99
115
|
}
|
|
100
116
|
matchBinding(app) { const b = this.options.bindings[app.bindingRef]; return b && b.instanceId === app.instanceId && b.companyId === app.companyId ? b : undefined; }
|
|
117
|
+
/** Grant mode: no local binding at all for this app (Portal-provisioned), or an explicit grant binding. */
|
|
118
|
+
grantMode(app) {
|
|
119
|
+
if (this.options.portalGrants === false)
|
|
120
|
+
return false;
|
|
121
|
+
const b = this.options.bindings[app.bindingRef];
|
|
122
|
+
return !b || (b.credentialRef === PORTAL_GRANT_REF && !!this.matchBinding(app));
|
|
123
|
+
}
|
|
124
|
+
// Keyed by the runtime credential too: a rotated credential retires every grant minted under it.
|
|
125
|
+
async grantKey(app) { const credential = createHash('sha256').update(String(await this.options.credential())).digest('hex').slice(0, 32); return JSON.stringify([this.issuer, this.options.org, this.options.connectionId, credential, app.registrationId, app.instanceId, app.companyId]); }
|
|
126
|
+
async portalGrant(app) {
|
|
127
|
+
const key = await this.grantKey(app), cached = sharedGrants.get(key);
|
|
128
|
+
if (cached && cached.expires - 60000 > this.now())
|
|
129
|
+
return cached;
|
|
130
|
+
let response;
|
|
131
|
+
try {
|
|
132
|
+
response = await this.portal('/api/runtime/knowledge-grants', { registrationId: app.registrationId });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw Error('runtime_portal_unavailable');
|
|
136
|
+
}
|
|
137
|
+
if (!response.ok)
|
|
138
|
+
throw Error(response.status === 403 ? 'runtime_action_denied' : response.status === 401 ? 'runtime_config_unavailable' : 'runtime_grant_unavailable');
|
|
139
|
+
const r = await boundedJson(response, 16384).catch(() => { throw Error('runtime_grant_invalid'); });
|
|
140
|
+
if (!r || typeof r.grant !== 'string' || !GRANT_PATTERN.test(r.grant) || r.registrationId !== app.registrationId || r.instanceId !== app.instanceId || r.companyId !== app.companyId || typeof r.expiresAt !== 'number' || !Number.isFinite(r.expiresAt) || r.expiresAt <= this.now() || (r.endpoint !== null && r.endpoint !== undefined && !text(r.endpoint)))
|
|
141
|
+
throw Error('runtime_grant_invalid');
|
|
142
|
+
const grant = { grant: r.grant, endpoint: r.endpoint ? endpoint(r.endpoint) : null, expires: r.expiresAt };
|
|
143
|
+
if (sharedGrants.size >= 256)
|
|
144
|
+
for (const [k, v] of sharedGrants)
|
|
145
|
+
if (v.expires <= this.now())
|
|
146
|
+
sharedGrants.delete(k);
|
|
147
|
+
if (sharedGrants.size < 256)
|
|
148
|
+
sharedGrants.set(key, grant);
|
|
149
|
+
return grant;
|
|
150
|
+
}
|
|
101
151
|
async current(force = false) {
|
|
102
152
|
if (force || !this.cached || this.now() - this.cached.checked >= 30000) {
|
|
103
153
|
this.refreshing ??= this.refresh().finally(() => { this.refreshing = undefined; });
|
|
@@ -113,7 +163,7 @@ export class RuntimeConnector {
|
|
|
113
163
|
throw Error('runtime_authorization_expired');
|
|
114
164
|
return this.cached;
|
|
115
165
|
}
|
|
116
|
-
async capabilities(force = false) { const c = await this.current(force); return { revision: c.revision, expiresAt: c.expires, apps: c.apps.map(a => ({ registrationId: a.registrationId, appId: a.appId, actions: [...a.actions], available: !!this.matchBinding(a), operations: allOperations.filter(op => requiredActions(op).every(action => a.actions.includes(action))) })) }; }
|
|
166
|
+
async capabilities(force = false) { const c = await this.current(force); return { revision: c.revision, expiresAt: c.expires, apps: c.apps.map(a => ({ registrationId: a.registrationId, appId: a.appId, actions: [...a.actions], available: !!this.matchBinding(a) || (this.grantMode(a) && (!!this.options.bindings[a.bindingRef] || this.reachableByGrant.has(a.registrationId))), operations: allOperations.filter(op => requiredActions(op).every(action => a.actions.includes(action))) })) }; }
|
|
117
167
|
async call(registrationId, operation, input = {}, signal) {
|
|
118
168
|
let revision;
|
|
119
169
|
let outcome = 'denied';
|
|
@@ -123,8 +173,8 @@ export class RuntimeConnector {
|
|
|
123
173
|
const app = c.apps.find(a => a.registrationId === registrationId);
|
|
124
174
|
if (!app || !allOperations.includes(operation) || !requiredActions(operation).every(action => app.actions.includes(action)))
|
|
125
175
|
throw Error('runtime_action_denied');
|
|
126
|
-
const b = this.matchBinding(app);
|
|
127
|
-
if (!b)
|
|
176
|
+
const b = this.matchBinding(app), viaGrant = this.grantMode(app);
|
|
177
|
+
if (!b && !viaGrant)
|
|
128
178
|
throw Error('runtime_binding_missing');
|
|
129
179
|
let path, method = 'GET', body, idempotencyKey;
|
|
130
180
|
const company = component(app.companyId);
|
|
@@ -241,7 +291,11 @@ export class RuntimeConnector {
|
|
|
241
291
|
if (path.startsWith('/api/knowledge/'))
|
|
242
292
|
path += `?companyId=${company}`;
|
|
243
293
|
// Recheck after asynchronous credential resolution; no call may start with an expired lease.
|
|
244
|
-
const
|
|
294
|
+
const grant = viaGrant ? await this.portalGrant(app) : undefined;
|
|
295
|
+
const target = grant ? (b?.endpoint ?? grant.endpoint) : b.endpoint;
|
|
296
|
+
if (!target)
|
|
297
|
+
throw Error('runtime_binding_missing');
|
|
298
|
+
const token = grant ? grant.grant : await this.options.resolveCredential(b.credentialRef);
|
|
245
299
|
if (!text(token) || /[\r\n]/.test(token))
|
|
246
300
|
throw Error('runtime_credential_unavailable');
|
|
247
301
|
if (!this.cached || this.cached.revision !== c.revision)
|
|
@@ -250,7 +304,10 @@ export class RuntimeConnector {
|
|
|
250
304
|
throw Error('runtime_authorization_expired');
|
|
251
305
|
outcome = 'failed';
|
|
252
306
|
const deadline = AbortSignal.timeout(operation.startsWith('memory_native_') ? 610000 : operation.startsWith('memory_') ? 120000 : 30000);
|
|
253
|
-
const response = await this.fetcher(new URL(path, endpoint(
|
|
307
|
+
const response = await this.fetcher(new URL(path, endpoint(target)), { method, headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}), ...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: 'error', signal: signal ? AbortSignal.any([signal, deadline]) : deadline });
|
|
308
|
+
// A rejected grant is dropped before any result handling, so the next call asks Portal again.
|
|
309
|
+
if (grant && (response.status === 401 || response.status === 403))
|
|
310
|
+
sharedGrants.delete(await this.grantKey(app));
|
|
254
311
|
if (operation === 'memory_tools' || operation.startsWith('memory_native_')) {
|
|
255
312
|
const result = await boundedJson(response, 8388608);
|
|
256
313
|
if (!result || typeof result.ok !== 'boolean' || (!response.ok && result.ok))
|