@tealbrick/portal 0.3.0-rc.4 → 0.3.0-rc.5

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/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
- const missing = apps.some(a => !this.matchBinding(a));
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 token = await this.options.resolveCredential(b.credentialRef);
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(b.endpoint)), { 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 });
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))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tealbrick/portal",
3
- "version": "0.3.0-rc.4",
3
+ "version": "0.3.0-rc.5",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "files": [