agent-relay-server 0.9.0 → 0.10.1
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/README.md +12 -14
- package/package.json +18 -1
- package/public/index.html +979 -2575
- package/public/manifest.webmanifest +6 -6
- package/public/sw.js +16 -10
- package/recipes/code-review.yaml +26 -0
- package/recipes/debug.yaml +20 -0
- package/recipes/feature.yaml +26 -0
- package/recipes/refactor.yaml +20 -0
- package/recipes/test.yaml +20 -0
- package/runner/src/adapter.ts +69 -0
- package/runner/src/config.ts +144 -0
- package/scripts/orchestrator-spawn-smoke.ts +2 -9
- package/src/agent-spawn.ts +2 -94
- package/src/automations.ts +774 -0
- package/src/bus-outbox.ts +75 -0
- package/src/bus.ts +439 -0
- package/src/cli.ts +251 -5
- package/src/commands-db.ts +160 -0
- package/src/config.ts +1 -1
- package/src/connectors.ts +29 -9
- package/src/daemon.ts +1 -0
- package/src/db.ts +241 -34
- package/src/events.ts +33 -0
- package/src/index.ts +94 -5
- package/src/recipe-db.ts +163 -0
- package/src/recipe-loader.ts +100 -0
- package/src/recipe-runner.ts +206 -0
- package/src/recipe-validator.ts +85 -0
- package/src/routes.ts +649 -155
- package/src/security.ts +128 -2
- package/src/sse.ts +42 -31
- package/src/token-db.ts +96 -0
- package/src/types.ts +1 -493
- package/src/upgrade.ts +14 -28
- package/public/dashboard/actions.js +0 -819
- package/public/dashboard/api.js +0 -336
- package/public/dashboard/app.js +0 -34
- package/public/dashboard/charts.js +0 -128
- package/public/dashboard/computed.js +0 -693
- package/public/dashboard/constants.js +0 -28
- package/public/dashboard/display.js +0 -345
- package/public/dashboard/state.js +0 -129
- package/public/dashboard/utils.js +0 -207
package/src/security.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { AUTH_TOKEN, CORS_ORIGINS, getIntegrationTokens, type IntegrationTokenConfig } from "./config";
|
|
2
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { getDb } from "./db";
|
|
6
|
+
import type { ComponentToken } from "./types";
|
|
2
7
|
|
|
3
8
|
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
4
9
|
|
|
@@ -65,9 +70,11 @@ export function corsPreflight(req: Request): Response {
|
|
|
65
70
|
|
|
66
71
|
export function isAuthorized(req: Request): boolean {
|
|
67
72
|
const token = authToken();
|
|
68
|
-
if (!token) return true;
|
|
73
|
+
if (!token && !extractToken(req)) return true;
|
|
69
74
|
|
|
70
|
-
|
|
75
|
+
const presented = extractToken(req);
|
|
76
|
+
if (token && presented === token) return true;
|
|
77
|
+
return Boolean(presented && verifyComponentToken(presented));
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
type IntegrationAuth = IntegrationTokenConfig;
|
|
@@ -98,10 +105,14 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
98
105
|
if (pathname === "/api/connectors" || pathname.startsWith("/api/connectors/")) return method === "GET" ? "connectors:read" : "system:write";
|
|
99
106
|
if (pathname.startsWith("/api/channels") || pathname.startsWith("/api/channel-bindings")) return method === "GET" ? "channels:read" : "channels:write";
|
|
100
107
|
if (pathname === "/api/integrations" || pathname.startsWith("/api/integrations/")) return method === "GET" ? "integrations:read" : "integrations:write";
|
|
108
|
+
if (pathname.startsWith("/api/automations") || pathname.startsWith("/api/automation-runs")) return method === "GET" ? "automations:read" : "automations:write";
|
|
101
109
|
if (pathname.startsWith("/api/agents")) return method === "GET" ? "agents:read" : "agents:write";
|
|
102
110
|
if (pathname.startsWith("/api/activity")) return method === "GET" ? "activity:read" : "activity:write";
|
|
103
111
|
if (pathname.startsWith("/api/inbox")) return method === "GET" ? "messages:read" : "messages:write";
|
|
104
112
|
if (pathname.startsWith("/api/messages")) return method === "GET" ? "messages:read" : "messages:write";
|
|
113
|
+
if (pathname.startsWith("/api/commands")) return method === "GET" ? "commands:read" : "commands:write";
|
|
114
|
+
if (pathname.startsWith("/api/recipes")) return method === "GET" ? "recipes:read" : "recipes:write";
|
|
115
|
+
if (pathname.startsWith("/api/tokens")) return "system:write";
|
|
105
116
|
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "tasks:read" : "tasks:write";
|
|
106
117
|
if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
|
|
107
118
|
if (pathname.startsWith("/api/system/")) return "system:write";
|
|
@@ -109,6 +120,12 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
export function isScopedRequestAuthorized(req: Request): boolean {
|
|
123
|
+
const component = getComponentAuth(req);
|
|
124
|
+
if (component) {
|
|
125
|
+
const required = requiredComponentScopeFor(req.method, new URL(req.url).pathname);
|
|
126
|
+
return required ? hasComponentScope(component, required) : false;
|
|
127
|
+
}
|
|
128
|
+
|
|
112
129
|
const auth = getIntegrationAuth(req);
|
|
113
130
|
if (!auth) return false;
|
|
114
131
|
const pathname = new URL(req.url).pathname;
|
|
@@ -121,6 +138,61 @@ export function isScopedRequestAuthorized(req: Request): boolean {
|
|
|
121
138
|
return scope ? hasIntegrationScope(auth, scope) : false;
|
|
122
139
|
}
|
|
123
140
|
|
|
141
|
+
export function getComponentAuth(req: Request): ComponentToken | null {
|
|
142
|
+
const token = extractToken(req);
|
|
143
|
+
return token ? verifyComponentToken(token) : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function hasComponentScope(auth: ComponentToken, requiredScope: string): boolean {
|
|
147
|
+
if (auth.scope.includes("admin:*")) return true;
|
|
148
|
+
if (auth.scope.includes(requiredScope)) return true;
|
|
149
|
+
const [category] = requiredScope.split(":");
|
|
150
|
+
return Boolean(category && auth.scope.includes(`${category}:*`));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function requiredComponentScopeFor(method: string, pathname: string): string | null {
|
|
154
|
+
if (pathname === "/api/stats" || pathname === "/api/health") return "agent:read";
|
|
155
|
+
if (pathname === "/api/events") return "message:read";
|
|
156
|
+
if (pathname.startsWith("/api/commands")) {
|
|
157
|
+
if (method === "GET") return "command:*";
|
|
158
|
+
return "command:*";
|
|
159
|
+
}
|
|
160
|
+
if (pathname.startsWith("/api/tokens")) return "admin:*";
|
|
161
|
+
if (pathname === "/api/recipes/start") return "recipe:start";
|
|
162
|
+
if (pathname.startsWith("/api/recipes/") && pathname.endsWith("/stop")) return "recipe:stop";
|
|
163
|
+
if (pathname.startsWith("/api/recipes")) return method === "GET" ? "agent:read" : "recipe:start";
|
|
164
|
+
if (pathname.startsWith("/api/automations") || pathname.startsWith("/api/automation-runs")) return method === "GET" ? "automation:read" : "automation:write";
|
|
165
|
+
if (pathname.startsWith("/api/agents")) return method === "GET" ? "agent:read" : "agent:write";
|
|
166
|
+
if (pathname.startsWith("/api/messages") || pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
|
|
167
|
+
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
|
|
168
|
+
if (pathname.startsWith("/api/orchestrators")) return method === "GET" ? "agent:read" : "command:*";
|
|
169
|
+
if (pathname.startsWith("/api/system/")) return "admin:*";
|
|
170
|
+
return "admin:*";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function signComponentToken(payload: Omit<ComponentToken, "iat"> & { iat?: number }): string {
|
|
174
|
+
const header = { alg: "HS256", typ: "JWT" };
|
|
175
|
+
const body: ComponentToken = {
|
|
176
|
+
...payload,
|
|
177
|
+
iat: payload.iat ?? Math.floor(Date.now() / 1000),
|
|
178
|
+
};
|
|
179
|
+
const signingInput = `${base64urlJson(header)}.${base64urlJson(body)}`;
|
|
180
|
+
return `${signingInput}.${hmac(signingInput)}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function verifyComponentToken(token: string, nowSeconds = Math.floor(Date.now() / 1000)): ComponentToken | null {
|
|
184
|
+
const parts = token.split(".");
|
|
185
|
+
if (parts.length !== 3) return null;
|
|
186
|
+
const [headerRaw, payloadRaw, signature] = parts as [string, string, string];
|
|
187
|
+
const expected = hmac(`${headerRaw}.${payloadRaw}`);
|
|
188
|
+
if (!safeEqual(signature, expected)) return null;
|
|
189
|
+
const payload = parseBase64urlJson(payloadRaw);
|
|
190
|
+
if (!isComponentToken(payload)) return null;
|
|
191
|
+
if (payload.exp !== undefined && payload.exp <= nowSeconds) return null;
|
|
192
|
+
if (payload.jti && isTokenRevoked(payload.jti)) return null;
|
|
193
|
+
return payload;
|
|
194
|
+
}
|
|
195
|
+
|
|
124
196
|
export function forbidden(req: Request): Response {
|
|
125
197
|
return applyCors(req, Response.json({ error: "forbidden" }, { status: 403 }));
|
|
126
198
|
}
|
|
@@ -141,6 +213,60 @@ function extractToken(req: Request): string | null {
|
|
|
141
213
|
return bearer ?? req.headers.get("x-agent-relay-token") ?? new URL(req.url).searchParams.get("token");
|
|
142
214
|
}
|
|
143
215
|
|
|
216
|
+
function tokenSecret(): string {
|
|
217
|
+
const env = process.env.AGENT_RELAY_TOKEN_SECRET;
|
|
218
|
+
if (env) return env;
|
|
219
|
+
const path = join(process.env.HOME || ".", ".agent-relay", "token-secret");
|
|
220
|
+
if (existsSync(path)) return readFileSync(path, "utf8").trim();
|
|
221
|
+
const secret = randomBytes(32).toString("base64url");
|
|
222
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
223
|
+
writeFileSync(path, `${secret}\n`, { mode: 0o600 });
|
|
224
|
+
return secret;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function hmac(input: string): string {
|
|
228
|
+
return createHmac("sha256", tokenSecret()).update(input).digest("base64url");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function base64urlJson(value: unknown): string {
|
|
232
|
+
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseBase64urlJson(raw: string): unknown {
|
|
236
|
+
try {
|
|
237
|
+
return JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function safeEqual(a: string, b: string): boolean {
|
|
244
|
+
const left = Buffer.from(a);
|
|
245
|
+
const right = Buffer.from(b);
|
|
246
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isComponentToken(value: unknown): value is ComponentToken {
|
|
250
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
251
|
+
const record = value as Record<string, unknown>;
|
|
252
|
+
return typeof record.sub === "string" &&
|
|
253
|
+
typeof record.role === "string" &&
|
|
254
|
+
Array.isArray(record.scope) &&
|
|
255
|
+
record.scope.every((scope) => typeof scope === "string") &&
|
|
256
|
+
typeof record.iat === "number" &&
|
|
257
|
+
(record.exp === undefined || typeof record.exp === "number") &&
|
|
258
|
+
(record.jti === undefined || typeof record.jti === "string");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function isTokenRevoked(jti: string): boolean {
|
|
262
|
+
try {
|
|
263
|
+
const row = getDb().prepare("SELECT revoked_at FROM tokens WHERE jti = ?").get(jti) as { revoked_at?: number | null } | undefined;
|
|
264
|
+
return Boolean(row?.revoked_at);
|
|
265
|
+
} catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
144
270
|
function corsOrigins(): string[] {
|
|
145
271
|
const raw = process.env.AGENT_RELAY_CORS_ORIGINS;
|
|
146
272
|
if (raw === undefined) return CORS_ORIGINS;
|
package/src/sse.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getAgent, getOrchestrator } from "./db";
|
|
2
|
+
import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
|
|
2
3
|
import type { Message, Task } from "./types";
|
|
3
4
|
|
|
4
5
|
interface Connection {
|
|
@@ -10,6 +11,10 @@ interface Connection {
|
|
|
10
11
|
|
|
11
12
|
const connections = new Map<string, Connection>();
|
|
12
13
|
const encoder = new TextEncoder();
|
|
14
|
+
const SSE_KEEPALIVE_MS = 15_000;
|
|
15
|
+
const SSE_RETRY_MS = 5_000;
|
|
16
|
+
|
|
17
|
+
subscribeRelayEvents((event) => fanout(event));
|
|
13
18
|
|
|
14
19
|
export function createSSEStream(agentId: string | null): Response {
|
|
15
20
|
let connId: string;
|
|
@@ -20,12 +25,12 @@ export function createSSEStream(agentId: string | null): Response {
|
|
|
20
25
|
const keepalive = setInterval(() => {
|
|
21
26
|
try { controller.enqueue(encoder.encode(": keepalive\n\n")); }
|
|
22
27
|
catch { removeConnection(connId); }
|
|
23
|
-
},
|
|
28
|
+
}, SSE_KEEPALIVE_MS);
|
|
24
29
|
|
|
25
30
|
connections.set(connId, { id: connId, agentId, controller, keepalive });
|
|
26
31
|
|
|
27
32
|
controller.enqueue(encoder.encode(
|
|
28
|
-
`
|
|
33
|
+
`retry: ${SSE_RETRY_MS}\nevent: connected\ndata: ${JSON.stringify({ connectionId: connId })}\n\n`
|
|
29
34
|
));
|
|
30
35
|
},
|
|
31
36
|
cancel() {
|
|
@@ -37,6 +42,8 @@ export function createSSEStream(agentId: string | null): Response {
|
|
|
37
42
|
headers: {
|
|
38
43
|
"Content-Type": "text/event-stream",
|
|
39
44
|
"Cache-Control": "no-cache, no-transform",
|
|
45
|
+
"Pragma": "no-cache",
|
|
46
|
+
"Vary": "Accept",
|
|
40
47
|
"X-Accel-Buffering": "no",
|
|
41
48
|
},
|
|
42
49
|
});
|
|
@@ -71,7 +78,21 @@ function messageMatchesAgent(msg: Message, agentId: string): boolean {
|
|
|
71
78
|
return false;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
|
-
|
|
81
|
+
function fanout(event: RelayEvent): void {
|
|
82
|
+
if (event.type === "message.new") {
|
|
83
|
+
sendNewMessage(event.data as unknown as Message);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (event.type === "task.updated" || event.type === "task.created" || event.type === "task.claimed" || event.type === "task.status") {
|
|
87
|
+
sendTaskChanged(event.data as unknown as Task, event.type);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
for (const conn of connections.values()) {
|
|
91
|
+
send(conn, event.type, event.data);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sendNewMessage(msg: Message): void {
|
|
75
96
|
for (const conn of connections.values()) {
|
|
76
97
|
if (!conn.agentId) {
|
|
77
98
|
send(conn, "message.new", msg);
|
|
@@ -83,49 +104,45 @@ export function emitNewMessage(msg: Message) {
|
|
|
83
104
|
}
|
|
84
105
|
}
|
|
85
106
|
|
|
107
|
+
export function emitNewMessage(msg: Message) {
|
|
108
|
+
emitRelayEvent({ type: "message.new", source: msg.from, subject: String(msg.id), data: msg as unknown as Record<string, unknown> });
|
|
109
|
+
}
|
|
110
|
+
|
|
86
111
|
export function emitAgentStatus(agentId: string) {
|
|
87
112
|
const agent = getAgent(agentId);
|
|
88
113
|
const data = agent ?? { id: agentId, status: "offline" };
|
|
89
|
-
|
|
90
|
-
send(conn, "agent.status", data);
|
|
91
|
-
}
|
|
114
|
+
emitRelayEvent({ type: "agent.status", source: "server", subject: agentId, data: data as unknown as Record<string, unknown> });
|
|
92
115
|
}
|
|
93
116
|
|
|
94
117
|
export function emitAgentRemoved(agentId: string) {
|
|
95
|
-
|
|
96
|
-
send(conn, "agent.removed", { id: agentId });
|
|
97
|
-
}
|
|
118
|
+
emitRelayEvent({ type: "agent.removed", source: "server", subject: agentId, data: { id: agentId } });
|
|
98
119
|
}
|
|
99
120
|
|
|
100
121
|
export function emitMessageClaimed(messageId: number, claimedBy: string, claimExpiresAt?: number) {
|
|
101
|
-
|
|
102
|
-
send(conn, "message.claimed", { messageId, claimedBy, claimExpiresAt });
|
|
103
|
-
}
|
|
122
|
+
emitRelayEvent({ type: "message.claimed", source: "server", subject: String(messageId), data: { messageId, claimedBy, claimExpiresAt } });
|
|
104
123
|
}
|
|
105
124
|
|
|
106
125
|
export function emitMessageClaimReleased(messageId: number) {
|
|
107
|
-
|
|
108
|
-
send(conn, "message.claim_released", { messageId });
|
|
109
|
-
}
|
|
126
|
+
emitRelayEvent({ type: "message.claim_released", source: "server", subject: String(messageId), data: { messageId } });
|
|
110
127
|
}
|
|
111
128
|
|
|
112
129
|
export function emitMessageDeleted(messageId: number) {
|
|
113
|
-
|
|
114
|
-
send(conn, "message.deleted", { messageId });
|
|
115
|
-
}
|
|
130
|
+
emitRelayEvent({ type: "message.deleted", source: "server", subject: String(messageId), data: { messageId } });
|
|
116
131
|
}
|
|
117
132
|
|
|
118
|
-
|
|
133
|
+
function sendTaskChanged(task: Task, eventType = "task.updated"): void {
|
|
119
134
|
for (const conn of connections.values()) {
|
|
120
135
|
if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId)) continue;
|
|
121
136
|
send(conn, eventType, task);
|
|
122
137
|
}
|
|
123
138
|
}
|
|
124
139
|
|
|
140
|
+
export function emitTaskChanged(task: Task, eventType = "task.updated") {
|
|
141
|
+
emitRelayEvent({ type: eventType, source: task.source, subject: String(task.id), data: task as unknown as Record<string, unknown> });
|
|
142
|
+
}
|
|
143
|
+
|
|
125
144
|
export function emitChannelActivity(activity: Record<string, unknown>) {
|
|
126
|
-
|
|
127
|
-
send(conn, "channel.activity", activity);
|
|
128
|
-
}
|
|
145
|
+
emitRelayEvent({ type: "channel.activity", source: "server", data: activity });
|
|
129
146
|
}
|
|
130
147
|
|
|
131
148
|
export function getConnectionCount(): number {
|
|
@@ -145,19 +162,13 @@ function targetMatchesAgent(target: string, agentId: string): boolean {
|
|
|
145
162
|
export function emitOrchestratorStatus(orchestratorId: string) {
|
|
146
163
|
const orch = getOrchestrator(orchestratorId);
|
|
147
164
|
const data = orch ?? { id: orchestratorId, status: "offline" };
|
|
148
|
-
|
|
149
|
-
send(conn, "orchestrator.status", data);
|
|
150
|
-
}
|
|
165
|
+
emitRelayEvent({ type: "orchestrator.status", source: "server", subject: orchestratorId, data: data as unknown as Record<string, unknown> });
|
|
151
166
|
}
|
|
152
167
|
|
|
153
168
|
export function emitOrchestratorRemoved(orchestratorId: string) {
|
|
154
|
-
|
|
155
|
-
send(conn, "orchestrator.removed", { id: orchestratorId });
|
|
156
|
-
}
|
|
169
|
+
emitRelayEvent({ type: "orchestrator.removed", source: "server", subject: orchestratorId, data: { id: orchestratorId } });
|
|
157
170
|
}
|
|
158
171
|
|
|
159
172
|
export function emitPoolBindingChanged(bindingId: string, channelId: string, previousAgentId: string | null, newAgentId: string | null) {
|
|
160
|
-
|
|
161
|
-
send(conn, "channel.pool.changed", { bindingId, channelId, previousAgentId, newAgentId, at: Date.now() });
|
|
162
|
-
}
|
|
173
|
+
emitRelayEvent({ type: "channel.pool.changed", source: "server", subject: bindingId, data: { bindingId, channelId, previousAgentId, newAgentId, at: Date.now() } });
|
|
163
174
|
}
|
package/src/token-db.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDb } from "./db";
|
|
3
|
+
import { signComponentToken } from "./security";
|
|
4
|
+
import type { ComponentToken } from "./types";
|
|
5
|
+
|
|
6
|
+
interface TokenRecord {
|
|
7
|
+
jti: string;
|
|
8
|
+
sub: string;
|
|
9
|
+
role: string;
|
|
10
|
+
scope: string[];
|
|
11
|
+
issuedAt: number;
|
|
12
|
+
expiresAt?: number;
|
|
13
|
+
revokedAt?: number;
|
|
14
|
+
createdBy?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TokenRow {
|
|
18
|
+
jti: string;
|
|
19
|
+
sub: string;
|
|
20
|
+
role: string;
|
|
21
|
+
scope: string;
|
|
22
|
+
issued_at: number;
|
|
23
|
+
expires_at: number | null;
|
|
24
|
+
revoked_at: number | null;
|
|
25
|
+
created_by: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createToken(input: {
|
|
29
|
+
sub: string;
|
|
30
|
+
role: string;
|
|
31
|
+
scope?: string[];
|
|
32
|
+
ttlSeconds?: number;
|
|
33
|
+
createdBy?: string;
|
|
34
|
+
}): { token: string; record: TokenRecord } {
|
|
35
|
+
const now = Math.floor(Date.now() / 1000);
|
|
36
|
+
const jti = randomUUID();
|
|
37
|
+
const scope = input.scope?.length ? input.scope : defaultScopes(input.role);
|
|
38
|
+
const expiresAt = input.ttlSeconds ? now + input.ttlSeconds : undefined;
|
|
39
|
+
const payload: ComponentToken = {
|
|
40
|
+
sub: input.sub,
|
|
41
|
+
role: input.role,
|
|
42
|
+
scope,
|
|
43
|
+
iat: now,
|
|
44
|
+
exp: expiresAt,
|
|
45
|
+
jti,
|
|
46
|
+
};
|
|
47
|
+
const token = signComponentToken(payload);
|
|
48
|
+
getDb().prepare(`
|
|
49
|
+
INSERT INTO tokens (jti, sub, role, scope, issued_at, expires_at, created_by)
|
|
50
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
51
|
+
`).run(jti, input.sub, input.role, JSON.stringify(scope), now, expiresAt ?? null, input.createdBy ?? null);
|
|
52
|
+
return { token, record: getToken(jti)! };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function listTokens(): TokenRecord[] {
|
|
56
|
+
const rows = getDb().prepare("SELECT * FROM tokens ORDER BY issued_at DESC").all() as TokenRow[];
|
|
57
|
+
return rows.map(rowToToken);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getToken(jti: string): TokenRecord | null {
|
|
61
|
+
const row = getDb().prepare("SELECT * FROM tokens WHERE jti = ?").get(jti) as TokenRow | undefined;
|
|
62
|
+
return row ? rowToToken(row) : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function revokeToken(jti: string): boolean {
|
|
66
|
+
return getDb().prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ? AND revoked_at IS NULL").run(Math.floor(Date.now() / 1000), jti).changes > 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function defaultScopes(role: string): string[] {
|
|
70
|
+
if (role === "provider" || role === "channel") return ["agent:read", "agent:write", "message:send", "message:read"];
|
|
71
|
+
if (role === "orchestrator") return ["agent:read", "agent:write", "command:*", "task:read", "task:write"];
|
|
72
|
+
if (role === "admin" || role === "dashboard") return ["admin:*"];
|
|
73
|
+
return ["agent:read"];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function rowToToken(row: TokenRow): TokenRecord {
|
|
77
|
+
return {
|
|
78
|
+
jti: row.jti,
|
|
79
|
+
sub: row.sub,
|
|
80
|
+
role: row.role,
|
|
81
|
+
scope: parseScope(row.scope),
|
|
82
|
+
issuedAt: row.issued_at,
|
|
83
|
+
expiresAt: row.expires_at ?? undefined,
|
|
84
|
+
revokedAt: row.revoked_at ?? undefined,
|
|
85
|
+
createdBy: row.created_by ?? undefined,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseScope(raw: string): string[] {
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(raw);
|
|
92
|
+
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|