@tealbrick/portal 0.1.0
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/LICENSE +21 -0
- package/NOTICE +6 -0
- package/README.md +59 -0
- package/dist/auth.d.ts +46 -0
- package/dist/auth.js +136 -0
- package/dist/client.d.ts +103 -0
- package/dist/client.js +158 -0
- package/dist/eve.d.ts +4 -0
- package/dist/eve.js +36 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tealbrick contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
@tealbrick/portal is an independent implementation authored for Tealbrick.
|
|
2
|
+
Public wire contracts were inspected in @kybernesis/enterprise 0.8.0 and
|
|
3
|
+
kybernesis-admin source commit 6e1651e538a6b4b5ef4b15927429f6ebb887841b.
|
|
4
|
+
No Kybernesis source is re-exported or imported. Legacy header names are explicit
|
|
5
|
+
protocol compatibility, not a dependency. Eve adapter targets Eve 0.49.0 public
|
|
6
|
+
channels/auth contracts. Third-party dependencies retain their own licenses.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @tealbrick/portal
|
|
2
|
+
|
|
3
|
+
Independent Tealbrick portal authentication and HTTP client. ESM, Node 24+. Core depends only on `jose`; the explicit `/eve` adapter supports Eve 0.55.x as an optional peer. No extension mounting, Knowledge, Buzz, tools, channels, skills or connections are contributed.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { portalAuth } from '@tealbrick/portal/eve';
|
|
7
|
+
const auth = portalAuth({ issuer: 'https://portal.example', org: 'org-id', agent: 'helper' });
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Both organization and registered agent name are required trusted deployment configuration. The adapter uses Eve's actual `ForbiddenError` and `UnauthenticatedError` classes. Authentication failure returns null; authorization denial throws ForbiddenError; key retrieval outage throws UnauthenticatedError with `identity_verification_unavailable`. Eve's error class still represents an authentication HTTP response; core reports outages as status 503 for custom HTTP handlers. No retry or implicit offline access.
|
|
11
|
+
|
|
12
|
+
Core `createPortalVerifier(options).authenticate(request)` returns an `AuthResult`. ES256 identity and policy signatures, issuer, mandatory expiry/issued-at, user/org matching and use/manage grants are checked. Identity kind/purpose separation excludes refresh, daemon and credential JWTs. A2A requires kind, caller, callee and audience `eve:ORG/AGENT`. Native bundle header is `X-Tealbrick-Bundle`. Legacy `X-Kybernesis-Bundle` is enabled explicitly by the default compatibility option; set `legacyBundleHeader:false` to disable it. Conflicting dual headers are rejected. Existing public identity sessions without a `typ` remain supported; `typ:user` is accepted.
|
|
13
|
+
|
|
14
|
+
## Future attachment gate
|
|
15
|
+
|
|
16
|
+
`verifier.verifyAttachment(token, principal, {agentId,audience,capability})` accepts only the exact unexpired principal returned by that verifier instance. An object assembled from labels or JSON is rejected. The required logical agent ID, audience and capability are trusted runtime configuration, never browser request inputs. For human requests the preceding grant authenticates access to this configured deployment; agent callers must also match the required agent ID.
|
|
17
|
+
|
|
18
|
+
Portal must sign `typ:attachment`, `orgId`, `sub:logicalAgentId`, `aud`, `capabilities:string[]`, `status:active`, `jti`, `iat`, `exp` with ES256. Maximum attachment lifetime is 300 seconds. Verification occurs for each protected operation, rejects inactive/expired/unrelated grants and never consumes unsigned manifests. Refresh must obtain a newly issued attachment: a revoked attachment can remain usable until its signed expiry, at most 300 seconds. Immediate revocation requires an online introspection mechanism, which this package does not provide. This verifies evidence only; it mounts no miniapp.
|
|
19
|
+
|
|
20
|
+
## HTTP client
|
|
21
|
+
|
|
22
|
+
`new PortalClient({issuer,token,fetch?,timeoutMs?})` provides startDevice, pollDevice, refresh, registerAgent, mintAgentCredential, startEnrollment, pollEnrollment, peers, mintAgentSession and resolveSpeaker. Generic `request<T>` is restricted to same-origin `/api/` paths. Redirects fail closed and requests time out after 15 seconds. No automatic retry of writes, credential persistence or logging. Keep credentials in trusted runtimes. `PortalHttpError.status/code` distinguishes pending/denied/server failures; response content and credential strings are excluded from its message.
|
|
23
|
+
|
|
24
|
+
`resolveSpeaker` makes a separate portal request for every provider/externalId pair and turn, with no room-level or durable cache. It returns a discriminated linked/unlinked/refused result for unlinked, suspended, unknown or ungranted senders; transport/server failures throw. Endpoint response shapes are checked at runtime; generic request<T> performs only envelope validation for unknown routes. The response is typed but is not authentication evidence until independently verified by the receiving agent. Enrollment approval and denial are operator-side portal actions, not automated client permissions.
|
|
25
|
+
|
|
26
|
+
## Evidence and limits
|
|
27
|
+
|
|
28
|
+
`npm run check --workspace @tealbrick/portal` builds declarations and runs disposable ES256 fixture tests: valid identity, mismatched user/org, same-name foreign org, missing/malformed grants, credential type confusion, invalid/expired signatures, A2A audience, key outage, actual Eve errors, attachment bindings/status, and transport security. No reference service mutations or AVM execution. Live portal lifecycle and desktop UAT, real JWKS outage/rotation, and all portal routes are separate integration evidence; this package does not claim complete enterprise parity. Refresh/enrollment client response structures preserve current wire compatibility and do not independently prove server authorization.
|
|
29
|
+
|
|
30
|
+
## Eve 0.55 channel and model example
|
|
31
|
+
|
|
32
|
+
Keep channel authorization in `agent/channels/eve.ts`:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { eveChannel } from 'eve/channels/eve';
|
|
36
|
+
import { portalAuth } from '@tealbrick/portal/eve';
|
|
37
|
+
|
|
38
|
+
export default eveChannel({
|
|
39
|
+
auth: [portalAuth({
|
|
40
|
+
issuer: process.env.TEALBRICK_ISSUER!,
|
|
41
|
+
org: process.env.TEALBRICK_ORG_ID!,
|
|
42
|
+
agent: 'helper',
|
|
43
|
+
})],
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Configure the model independently in `agent/agent.ts` using the runtime's own provider setup:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { defineAgent } from 'eve';
|
|
51
|
+
import { chatgpt } from 'eve/models/openai';
|
|
52
|
+
|
|
53
|
+
export default defineAgent({
|
|
54
|
+
model: chatgpt(process.env.TEALBRICK_MODEL),
|
|
55
|
+
reasoning: 'low',
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The portal token authenticates incoming requests. It is not a model-provider credential; model login and subscription configuration remain local to the agent runtime. `TEALBRICK_ORG_ID` is the actual portal organization ID, not its display name. Missing org/agent configuration fails during verifier creation.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type JWTVerifyGetKey } from "jose";
|
|
2
|
+
export type Grant = {
|
|
3
|
+
agent: string;
|
|
4
|
+
level: "use" | "manage";
|
|
5
|
+
};
|
|
6
|
+
export interface Principal {
|
|
7
|
+
id: string;
|
|
8
|
+
type: "user" | "agent";
|
|
9
|
+
org: string;
|
|
10
|
+
subject: string;
|
|
11
|
+
level?: Grant["level"];
|
|
12
|
+
callerAgent?: string;
|
|
13
|
+
email?: string;
|
|
14
|
+
}
|
|
15
|
+
export type AuthResult = {
|
|
16
|
+
ok: true;
|
|
17
|
+
principal: Principal;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
status: 401 | 403 | 503;
|
|
21
|
+
code: string;
|
|
22
|
+
};
|
|
23
|
+
export interface AuthOptions {
|
|
24
|
+
issuer: string;
|
|
25
|
+
agent: string;
|
|
26
|
+
org: string;
|
|
27
|
+
keys?: JWTVerifyGetKey;
|
|
28
|
+
/** Explicit legacy wire compatibility, default true. */ legacyBundleHeader?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export declare function createPortalVerifier(options: AuthOptions): {
|
|
31
|
+
authenticate(request: Request): Promise<AuthResult>;
|
|
32
|
+
/** Call for each operation. No unsigned object can produce verified attachment evidence. */
|
|
33
|
+
verifyAttachment(token: string, authenticated: Principal, required: {
|
|
34
|
+
agentId: string;
|
|
35
|
+
audience: string;
|
|
36
|
+
capability: string;
|
|
37
|
+
}): Promise<{
|
|
38
|
+
ok: true;
|
|
39
|
+
expiresAt: number;
|
|
40
|
+
attachmentId: string;
|
|
41
|
+
} | {
|
|
42
|
+
ok: false;
|
|
43
|
+
status: 401 | 403 | 503;
|
|
44
|
+
code: string;
|
|
45
|
+
}>;
|
|
46
|
+
};
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify, } from "jose";
|
|
2
|
+
const nonempty = (v) => typeof v === "string" && v.length > 0;
|
|
3
|
+
export function createPortalVerifier(options) {
|
|
4
|
+
if (!options.org || !options.agent)
|
|
5
|
+
throw new Error("Portal organization and agent are required");
|
|
6
|
+
const authenticatedPrincipals = new WeakMap();
|
|
7
|
+
const accept = (principal, expiresAt) => {
|
|
8
|
+
Object.freeze(principal);
|
|
9
|
+
authenticatedPrincipals.set(principal, expiresAt);
|
|
10
|
+
return { ok: true, principal };
|
|
11
|
+
};
|
|
12
|
+
const keys = options.keys ??
|
|
13
|
+
createRemoteJWKSet(new URL(`${options.issuer.replace(/\/$/, "")}/api/jwks`));
|
|
14
|
+
async function verify(token, audience) {
|
|
15
|
+
return (await jwtVerify(token, keys, {
|
|
16
|
+
issuer: options.issuer,
|
|
17
|
+
algorithms: ["ES256"],
|
|
18
|
+
requiredClaims: ["exp", "iat"],
|
|
19
|
+
...(audience ? { audience } : {}),
|
|
20
|
+
})).payload;
|
|
21
|
+
}
|
|
22
|
+
function failure(error) {
|
|
23
|
+
const code = error?.code;
|
|
24
|
+
const rejected = new Set([
|
|
25
|
+
"ERR_JWT_EXPIRED",
|
|
26
|
+
"ERR_JWT_CLAIM_VALIDATION_FAILED",
|
|
27
|
+
"ERR_JWS_SIGNATURE_VERIFICATION_FAILED",
|
|
28
|
+
"ERR_JWS_INVALID",
|
|
29
|
+
"ERR_JWT_INVALID",
|
|
30
|
+
"ERR_JOSE_ALG_NOT_ALLOWED",
|
|
31
|
+
"ERR_JWKS_NO_MATCHING_KEY",
|
|
32
|
+
"ERR_JWKS_MULTIPLE_MATCHING_KEYS",
|
|
33
|
+
]);
|
|
34
|
+
return rejected.has(code ?? "")
|
|
35
|
+
? { ok: false, status: 401, code: "invalid_session" }
|
|
36
|
+
: { ok: false, status: 503, code: "identity_verification_unavailable" };
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
async authenticate(request) {
|
|
40
|
+
const token = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "")?.[1];
|
|
41
|
+
const native = request.headers.get("x-tealbrick-bundle");
|
|
42
|
+
const legacy = options.legacyBundleHeader === false
|
|
43
|
+
? null
|
|
44
|
+
: request.headers.get("x-kybernesis-bundle");
|
|
45
|
+
if (!token || (native && legacy && native !== legacy))
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
status: 401,
|
|
49
|
+
code: "missing_or_conflicting_credentials",
|
|
50
|
+
};
|
|
51
|
+
try {
|
|
52
|
+
const id = await verify(token);
|
|
53
|
+
if (!nonempty(id.sub) ||
|
|
54
|
+
!nonempty(id.org) ||
|
|
55
|
+
(options.org && id.org !== options.org))
|
|
56
|
+
return { ok: false, status: 401, code: "invalid_identity" };
|
|
57
|
+
const bundle = native ?? legacy;
|
|
58
|
+
if (!bundle) {
|
|
59
|
+
if (id.kind !== "a2a" || !nonempty(id.caller))
|
|
60
|
+
return { ok: false, status: 401, code: "bundle_required" };
|
|
61
|
+
if (id.callee !== options.agent)
|
|
62
|
+
return { ok: false, status: 403, code: "wrong_callee" };
|
|
63
|
+
await verify(token, `eve:${id.org}/${options.agent}`);
|
|
64
|
+
return accept({
|
|
65
|
+
id: id.sub,
|
|
66
|
+
type: "agent",
|
|
67
|
+
org: id.org,
|
|
68
|
+
callerAgent: id.caller,
|
|
69
|
+
subject: `agent:${id.org}/${id.caller}`,
|
|
70
|
+
}, id.exp);
|
|
71
|
+
}
|
|
72
|
+
if (id.kind !== undefined ||
|
|
73
|
+
id.purpose !== undefined ||
|
|
74
|
+
(id.typ !== undefined && id.typ !== "user"))
|
|
75
|
+
return { ok: false, status: 401, code: "wrong_identity_type" };
|
|
76
|
+
const policy = await verify(bundle);
|
|
77
|
+
if (policy.user !== id.sub ||
|
|
78
|
+
policy.org !== id.org ||
|
|
79
|
+
!Array.isArray(policy.agentGrants))
|
|
80
|
+
return { ok: false, status: 401, code: "bundle_mismatch" };
|
|
81
|
+
const grant = policy.agentGrants.find((g) => typeof g === "object" &&
|
|
82
|
+
g !== null &&
|
|
83
|
+
g.agent === options.agent &&
|
|
84
|
+
["use", "manage"].includes(g.level));
|
|
85
|
+
if (!grant)
|
|
86
|
+
return { ok: false, status: 403, code: "agent_not_granted" };
|
|
87
|
+
return accept({
|
|
88
|
+
id: id.sub,
|
|
89
|
+
type: "user",
|
|
90
|
+
org: id.org,
|
|
91
|
+
subject: id.sub,
|
|
92
|
+
level: grant.level,
|
|
93
|
+
...(nonempty(id.email) ? { email: id.email } : {}),
|
|
94
|
+
}, Math.min(id.exp, policy.exp));
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
return failure(error);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
/** Call for each operation. No unsigned object can produce verified attachment evidence. */
|
|
101
|
+
async verifyAttachment(token, authenticated, required) {
|
|
102
|
+
try {
|
|
103
|
+
if ((authenticatedPrincipals.get(authenticated) ?? 0) <=
|
|
104
|
+
Date.now() / 1000)
|
|
105
|
+
return { ok: false, status: 403, code: "unverified_principal" };
|
|
106
|
+
const attachment = await verify(token, required.audience);
|
|
107
|
+
if (attachment.iat > Date.now() / 1000 ||
|
|
108
|
+
attachment.exp <= attachment.iat ||
|
|
109
|
+
attachment.exp - attachment.iat > 300 ||
|
|
110
|
+
attachment.typ !== "attachment" ||
|
|
111
|
+
attachment.orgId !== authenticated.org ||
|
|
112
|
+
attachment.sub !== required.agentId ||
|
|
113
|
+
attachment.status !== "active" ||
|
|
114
|
+
!nonempty(attachment.jti) ||
|
|
115
|
+
!Array.isArray(attachment.capabilities) ||
|
|
116
|
+
!attachment.capabilities.includes(required.capability))
|
|
117
|
+
return { ok: false, status: 403, code: "attachment_not_authorized" };
|
|
118
|
+
// Agent-authenticated calls cannot substitute a different logical agent ID.
|
|
119
|
+
if (authenticated.type === "agent" &&
|
|
120
|
+
authenticated.id !== required.agentId)
|
|
121
|
+
return { ok: false, status: 403, code: "attachment_agent_mismatch" };
|
|
122
|
+
return {
|
|
123
|
+
ok: true,
|
|
124
|
+
expiresAt: attachment.exp,
|
|
125
|
+
attachmentId: attachment.jti,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
const f = failure(error);
|
|
130
|
+
if (!f.ok)
|
|
131
|
+
return f;
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export interface IdentitySession {
|
|
2
|
+
issuer: string;
|
|
3
|
+
token: string;
|
|
4
|
+
bundle: string;
|
|
5
|
+
jwks: {
|
|
6
|
+
keys: unknown[];
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export interface DeviceAuthorization {
|
|
10
|
+
device_code: string;
|
|
11
|
+
user_code: string;
|
|
12
|
+
verification_uri: string;
|
|
13
|
+
verification_uri_complete: string;
|
|
14
|
+
expires_in: number;
|
|
15
|
+
interval: number;
|
|
16
|
+
}
|
|
17
|
+
export declare class PortalHttpError extends Error {
|
|
18
|
+
readonly status: number;
|
|
19
|
+
readonly code: string;
|
|
20
|
+
readonly link?: string | undefined;
|
|
21
|
+
constructor(status: number, code: string, link?: string | undefined);
|
|
22
|
+
}
|
|
23
|
+
export interface ClientOptions {
|
|
24
|
+
issuer: string;
|
|
25
|
+
fetch?: typeof fetch;
|
|
26
|
+
token?: string | (() => string | Promise<string>);
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}
|
|
29
|
+
/** No credential storage or logging; instantiate in a trusted runtime for agent credentials. */
|
|
30
|
+
export declare class PortalClient {
|
|
31
|
+
private options;
|
|
32
|
+
private base;
|
|
33
|
+
constructor(options: ClientOptions);
|
|
34
|
+
request<T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T>;
|
|
35
|
+
startDevice(input?: {
|
|
36
|
+
deviceId?: string;
|
|
37
|
+
deviceLabel?: string;
|
|
38
|
+
}): Promise<DeviceAuthorization>;
|
|
39
|
+
pollDevice(deviceCode: string): Promise<IdentitySession & {
|
|
40
|
+
refresh_token?: string;
|
|
41
|
+
}>;
|
|
42
|
+
refresh(refreshToken: string): Promise<IdentitySession & {
|
|
43
|
+
refresh_token: string;
|
|
44
|
+
}>;
|
|
45
|
+
registerAgent(name: string, url: string): Promise<{
|
|
46
|
+
ok: true;
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
url: string;
|
|
50
|
+
created: boolean;
|
|
51
|
+
}>;
|
|
52
|
+
mintAgentCredential(agent: string): Promise<{
|
|
53
|
+
credential: string;
|
|
54
|
+
}>;
|
|
55
|
+
startEnrollment(input: {
|
|
56
|
+
daemonId: string;
|
|
57
|
+
kind?: "desktop" | "server";
|
|
58
|
+
label?: string;
|
|
59
|
+
endpoint?: string;
|
|
60
|
+
}): Promise<DeviceAuthorization>;
|
|
61
|
+
pollEnrollment(deviceCode: string): Promise<{
|
|
62
|
+
credential: string;
|
|
63
|
+
org: string;
|
|
64
|
+
daemon_id: string;
|
|
65
|
+
}>;
|
|
66
|
+
peers(): Promise<{
|
|
67
|
+
peers: Array<{
|
|
68
|
+
name: string;
|
|
69
|
+
url: string;
|
|
70
|
+
purpose: string;
|
|
71
|
+
}>;
|
|
72
|
+
}>;
|
|
73
|
+
mintAgentSession(callee: string, onBehalfOf?: {
|
|
74
|
+
id: string;
|
|
75
|
+
label?: string;
|
|
76
|
+
}): Promise<{
|
|
77
|
+
token: string;
|
|
78
|
+
callee: {
|
|
79
|
+
name: string;
|
|
80
|
+
deploymentUrl: string | null;
|
|
81
|
+
};
|
|
82
|
+
expiresAt: number;
|
|
83
|
+
}>;
|
|
84
|
+
/** Resolve each sender separately. Never substitute a room identity. No caching by design. */
|
|
85
|
+
resolveSpeaker(provider: string, externalId: string, label?: string): Promise<SpeakerResolution>;
|
|
86
|
+
}
|
|
87
|
+
export type SpeakerResolution = ({
|
|
88
|
+
linked: true;
|
|
89
|
+
} & Omit<IdentitySession, "jwks"> & {
|
|
90
|
+
expiresIn: number;
|
|
91
|
+
user: {
|
|
92
|
+
id: string;
|
|
93
|
+
email: string;
|
|
94
|
+
displayName: string;
|
|
95
|
+
};
|
|
96
|
+
}) | {
|
|
97
|
+
linked: false;
|
|
98
|
+
reason: "not_linked";
|
|
99
|
+
link: string;
|
|
100
|
+
} | {
|
|
101
|
+
linked: false;
|
|
102
|
+
reason: "agent_not_granted" | "user_suspended" | "unknown_user";
|
|
103
|
+
};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
export class PortalHttpError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
link;
|
|
5
|
+
constructor(status, code, link) {
|
|
6
|
+
super(`Portal request failed (${status}, ${code})`);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.link = link;
|
|
10
|
+
this.name = "PortalHttpError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** No credential storage or logging; instantiate in a trusted runtime for agent credentials. */
|
|
14
|
+
export class PortalClient {
|
|
15
|
+
options;
|
|
16
|
+
base;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
this.base = new URL(options.issuer);
|
|
20
|
+
if (!["http:", "https:"].includes(this.base.protocol) ||
|
|
21
|
+
this.base.username ||
|
|
22
|
+
this.base.password)
|
|
23
|
+
throw new Error("Invalid portal URL");
|
|
24
|
+
}
|
|
25
|
+
async request(path, body, signal) {
|
|
26
|
+
if (!path.startsWith("/api/") || path.includes("..") || path.includes("\\"))
|
|
27
|
+
throw new Error("Invalid portal API path");
|
|
28
|
+
const url = new URL(path, this.base);
|
|
29
|
+
if (url.origin !== this.base.origin)
|
|
30
|
+
throw new Error("Cross-origin portal request");
|
|
31
|
+
const token = typeof this.options.token === "function"
|
|
32
|
+
? await this.options.token()
|
|
33
|
+
: this.options.token;
|
|
34
|
+
const response = await (this.options.fetch ?? fetch)(url, {
|
|
35
|
+
method: body === undefined ? "GET" : "POST",
|
|
36
|
+
headers: {
|
|
37
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
38
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
39
|
+
},
|
|
40
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
41
|
+
redirect: "error",
|
|
42
|
+
signal: signal
|
|
43
|
+
? AbortSignal.any([
|
|
44
|
+
signal,
|
|
45
|
+
AbortSignal.timeout(this.options.timeoutMs ?? 15000),
|
|
46
|
+
])
|
|
47
|
+
: AbortSignal.timeout(this.options.timeoutMs ?? 15000),
|
|
48
|
+
});
|
|
49
|
+
const data = await response.json().catch(() => null);
|
|
50
|
+
if (!response.ok)
|
|
51
|
+
throw new PortalHttpError(response.status, typeof data?.error === "string" && /^[a-z_]+$/.test(data.error)
|
|
52
|
+
? data.error
|
|
53
|
+
: "request_failed", typeof data?.link === "string" &&
|
|
54
|
+
new URL(data.link, this.base).origin === this.base.origin
|
|
55
|
+
? data.link
|
|
56
|
+
: undefined);
|
|
57
|
+
if (!data || typeof data !== "object")
|
|
58
|
+
throw new PortalHttpError(502, "invalid_response");
|
|
59
|
+
validateResponse(path, data);
|
|
60
|
+
return data;
|
|
61
|
+
}
|
|
62
|
+
startDevice(input = {}) {
|
|
63
|
+
return this.request("/api/oauth/device", input);
|
|
64
|
+
}
|
|
65
|
+
pollDevice(deviceCode) {
|
|
66
|
+
return this.request("/api/oauth/token", { device_code: deviceCode });
|
|
67
|
+
}
|
|
68
|
+
refresh(refreshToken) {
|
|
69
|
+
return this.request("/api/oauth/refresh", { refresh_token: refreshToken });
|
|
70
|
+
}
|
|
71
|
+
registerAgent(name, url) {
|
|
72
|
+
return this.request("/api/agents/register", { name, url });
|
|
73
|
+
}
|
|
74
|
+
mintAgentCredential(agent) {
|
|
75
|
+
return this.request("/api/agents/credential", {
|
|
76
|
+
agent,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
startEnrollment(input) {
|
|
80
|
+
return this.request("/api/daemon/enroll", input);
|
|
81
|
+
}
|
|
82
|
+
pollEnrollment(deviceCode) {
|
|
83
|
+
return this.request("/api/daemon/enroll/poll", { device_code: deviceCode });
|
|
84
|
+
}
|
|
85
|
+
peers() {
|
|
86
|
+
return this.request("/api/agent/peers");
|
|
87
|
+
}
|
|
88
|
+
mintAgentSession(callee, onBehalfOf) {
|
|
89
|
+
return this.request("/api/agent/session", { callee, ...(onBehalfOf ? { onBehalfOf } : {}) });
|
|
90
|
+
}
|
|
91
|
+
/** Resolve each sender separately. Never substitute a room identity. No caching by design. */
|
|
92
|
+
async resolveSpeaker(provider, externalId, label) {
|
|
93
|
+
try {
|
|
94
|
+
const session = await this.request("/api/agent/identity", {
|
|
95
|
+
provider,
|
|
96
|
+
externalId,
|
|
97
|
+
...(label ? { label } : {}),
|
|
98
|
+
});
|
|
99
|
+
return { linked: true, ...session };
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error instanceof PortalHttpError) {
|
|
103
|
+
if (error.status === 404 && error.code === "not_linked" && error.link)
|
|
104
|
+
return { linked: false, reason: "not_linked", link: error.link };
|
|
105
|
+
if (error.status >= 400 &&
|
|
106
|
+
error.status < 500 &&
|
|
107
|
+
["agent_not_granted", "user_suspended", "unknown_user"].includes(error.code))
|
|
108
|
+
return {
|
|
109
|
+
linked: false,
|
|
110
|
+
reason: error.code,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function validateResponse(path, data) {
|
|
118
|
+
const strings = (...fields) => fields.every((key) => typeof data[key] === "string" && data[key].length > 0);
|
|
119
|
+
let valid = true;
|
|
120
|
+
if (["/api/oauth/device", "/api/daemon/enroll"].includes(path))
|
|
121
|
+
valid =
|
|
122
|
+
strings("device_code", "user_code", "verification_uri", "verification_uri_complete") &&
|
|
123
|
+
Number.isFinite(data.expires_in) &&
|
|
124
|
+
data.expires_in > 0 &&
|
|
125
|
+
Number.isFinite(data.interval);
|
|
126
|
+
if (["/api/oauth/token", "/api/oauth/refresh", "/api/agent/identity"].includes(path))
|
|
127
|
+
valid = strings("issuer", "token", "bundle");
|
|
128
|
+
if (["/api/oauth/token", "/api/oauth/refresh"].includes(path))
|
|
129
|
+
valid = valid && Array.isArray(data.jwks?.keys) && strings("refresh_token");
|
|
130
|
+
if (path === "/api/agents/register")
|
|
131
|
+
valid = strings("id", "name", "url") && typeof data.created === "boolean";
|
|
132
|
+
if (path === "/api/agents/credential")
|
|
133
|
+
valid = strings("credential");
|
|
134
|
+
if (path === "/api/daemon/enroll/poll")
|
|
135
|
+
valid = strings("credential", "org", "daemon_id");
|
|
136
|
+
if (path === "/api/agent/session")
|
|
137
|
+
valid =
|
|
138
|
+
strings("token") &&
|
|
139
|
+
typeof data.callee?.name === "string" &&
|
|
140
|
+
(data.callee.deploymentUrl === null ||
|
|
141
|
+
typeof data.callee.deploymentUrl === "string") &&
|
|
142
|
+
Number.isFinite(data.expiresAt);
|
|
143
|
+
if (path === "/api/agent/peers")
|
|
144
|
+
valid =
|
|
145
|
+
Array.isArray(data.peers) &&
|
|
146
|
+
data.peers.every((p) => typeof p.name === "string" &&
|
|
147
|
+
typeof p.url === "string" &&
|
|
148
|
+
typeof p.purpose === "string");
|
|
149
|
+
if (path === "/api/agent/identity")
|
|
150
|
+
valid =
|
|
151
|
+
valid &&
|
|
152
|
+
Number.isFinite(data.expiresIn) &&
|
|
153
|
+
typeof data.user?.id === "string" &&
|
|
154
|
+
typeof data.user.email === "string" &&
|
|
155
|
+
typeof data.user.displayName === "string";
|
|
156
|
+
if (!valid)
|
|
157
|
+
throw new PortalHttpError(502, "invalid_response");
|
|
158
|
+
}
|
package/dist/eve.d.ts
ADDED
package/dist/eve.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ForbiddenError, UnauthenticatedError, } from "eve/channels/auth";
|
|
2
|
+
import { createPortalVerifier } from "./auth.js";
|
|
3
|
+
/** Explicit Eve 0.49 adapter. Mounts no tools, skills, channels, or connections. */
|
|
4
|
+
export function portalAuth(options) {
|
|
5
|
+
const verifier = createPortalVerifier(options);
|
|
6
|
+
return async (request) => {
|
|
7
|
+
const result = await verifier.authenticate(request);
|
|
8
|
+
if (!result.ok) {
|
|
9
|
+
if (result.status === 403)
|
|
10
|
+
throw new ForbiddenError({
|
|
11
|
+
code: result.code,
|
|
12
|
+
message: "Portal access denied.",
|
|
13
|
+
});
|
|
14
|
+
if (result.status === 503)
|
|
15
|
+
throw new UnauthenticatedError({
|
|
16
|
+
code: result.code,
|
|
17
|
+
message: "Portal identity verification is temporarily unavailable.",
|
|
18
|
+
});
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const p = result.principal;
|
|
22
|
+
return {
|
|
23
|
+
authenticator: "tealbrick",
|
|
24
|
+
issuer: options.issuer,
|
|
25
|
+
principalId: p.id,
|
|
26
|
+
principalType: p.type,
|
|
27
|
+
subject: p.subject,
|
|
28
|
+
attributes: {
|
|
29
|
+
org: p.org,
|
|
30
|
+
...(p.level ? { agentGrantLevel: p.level } : {}),
|
|
31
|
+
...(p.email ? { email: p.email } : {}),
|
|
32
|
+
...(p.callerAgent ? { callerAgent: p.callerAgent, kind: "a2a" } : {}),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tealbrick/portal",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"README.md",
|
|
9
|
+
"LICENSE",
|
|
10
|
+
"NOTICE"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=24"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./eve": {
|
|
21
|
+
"types": "./dist/eve.d.ts",
|
|
22
|
+
"import": "./dist/eve.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.json",
|
|
27
|
+
"test": "node --test test/*.test.mjs",
|
|
28
|
+
"check": "npm run build && npm test"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"jose": "^6.1.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"eve": ">=0.55.0 <0.56.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"eve": {
|
|
38
|
+
"optional": true
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"typescript": "^5.9.3",
|
|
43
|
+
"@types/node": "^24.0.0"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"registry": "https://registry.npmjs.org"
|
|
48
|
+
},
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/Doppelabs/tealbrick-packages.git",
|
|
52
|
+
"directory": "packages/portal"
|
|
53
|
+
}
|
|
54
|
+
}
|