@xfey/tutti 0.1.122 → 0.1.124
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/server-shell/local-console/browser-bridge.d.ts +10 -0
- package/dist/server-shell/local-console/browser-bridge.js +286 -0
- package/dist/server-shell/local-console/managed-project-routes.js +14 -2
- package/dist/server-shell/local-console/managed-project-service.d.ts +10 -0
- package/dist/server-shell/local-console/managed-project-service.js +11 -1
- package/dist/server-shell/local-console/project-service.d.ts +1 -0
- package/dist/server-shell/local-console/project-service.js +13 -0
- package/dist/server-shell/local-console/server.js +6 -0
- package/dist/server-shell/local-console/session.d.ts +5 -1
- package/dist/server-shell/local-console/session.js +20 -4
- package/node_modules/@tutti/relay-client/dist/project-authority.d.ts +12 -0
- package/node_modules/@tutti/relay-client/dist/project-authority.js +12 -0
- package/node_modules/@tutti/shared/dist/schemas/api/account-projects.d.ts +14 -0
- package/node_modules/@tutti/shared/dist/schemas/api/account-projects.js +19 -0
- package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +1 -0
- package/node_modules/@tutti/shared/dist/schemas/api/index.js +1 -0
- package/package.json +1 -1
- package/web/assets/{homepage-motion-scene-ClLzlKMk.js → homepage-motion-scene-LgzXCs33.js} +1 -1
- package/web/assets/index-CNl6vYHM.css +1 -0
- package/web/assets/index-Cwv2ofv7.js +70 -0
- package/web/index.html +2 -2
- package/web/assets/index-DTaMPmTW.css +0 -1
- package/web/assets/index-v5xKB6AX.js +0 -70
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
import { type LocalConsoleSessionRegistry } from "./session.js";
|
|
3
|
+
/** No caller-controlled destinations, control routes, session cookies or header forwarding. */
|
|
4
|
+
export declare function isConsoleBridgeOperation(method: string, path: string): boolean;
|
|
5
|
+
export declare function registerConsoleBrowserBridge(app: FastifyInstance, options: {
|
|
6
|
+
sessions: LocalConsoleSessionRegistry;
|
|
7
|
+
now?: () => Date;
|
|
8
|
+
tuttiHome?: string;
|
|
9
|
+
}): void;
|
|
10
|
+
//# sourceMappingURL=browser-bridge.d.ts.map
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { resolveRelayUrl } from "../cli/launch.js";
|
|
3
|
+
import { serializeRuntimeLogLine } from "@tutti/shared/utils";
|
|
4
|
+
import { appendHostLogLine, getHostLogFilePath } from "../cli/machine-local.js";
|
|
5
|
+
import { LOCAL_CONSOLE_SESSION_COOKIE } from "./session.js";
|
|
6
|
+
const BASE = "/local-console/bridge/v1";
|
|
7
|
+
const IDLE_MS = 15 * 60_000;
|
|
8
|
+
const LIFETIME_MS = 12 * 60 * 60_000;
|
|
9
|
+
const secret = () => randomBytes(32).toString("base64url");
|
|
10
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
11
|
+
const tokenPattern = /^[A-Za-z0-9_-]{43}$/u;
|
|
12
|
+
/** No caller-controlled destinations, control routes, session cookies or header forwarding. */
|
|
13
|
+
export function isConsoleBridgeOperation(method, path) {
|
|
14
|
+
if (method === "GET") {
|
|
15
|
+
return ([
|
|
16
|
+
"/bootstrap",
|
|
17
|
+
"/projects",
|
|
18
|
+
"/heartbeat",
|
|
19
|
+
"/package-update",
|
|
20
|
+
"/package-update?refresh=true",
|
|
21
|
+
].includes(path) || /^\/projects\/prj_[A-Za-z0-9]{1,64}\/feishu-setup$/u.test(path));
|
|
22
|
+
}
|
|
23
|
+
return (method === "POST" &&
|
|
24
|
+
([
|
|
25
|
+
"/folders/pick",
|
|
26
|
+
"/providers/models",
|
|
27
|
+
"/projects/launch",
|
|
28
|
+
"/package-update",
|
|
29
|
+
"/account/managed-usage",
|
|
30
|
+
].includes(path) ||
|
|
31
|
+
/^\/projects\/prj_[A-Za-z0-9]{1,64}\/(?:open|invite|stop)$/u.test(path) ||
|
|
32
|
+
/^\/managed-projects\/(?:prepare|authorize|read|reconcile|validate|launch)$/u.test(path)));
|
|
33
|
+
}
|
|
34
|
+
function validOrigin(origin) {
|
|
35
|
+
if (typeof origin !== "string")
|
|
36
|
+
return false;
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(origin);
|
|
39
|
+
return (url.origin === origin &&
|
|
40
|
+
!url.username &&
|
|
41
|
+
!url.password &&
|
|
42
|
+
(url.protocol === "https:" || (url.protocol === "http:" && url.hostname === "127.0.0.1")));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function objectWithKeys(value, required, optional = []) {
|
|
49
|
+
return (typeof value === "object" &&
|
|
50
|
+
value !== null &&
|
|
51
|
+
!Array.isArray(value) &&
|
|
52
|
+
required.every((key) => Object.hasOwn(value, key)) &&
|
|
53
|
+
Object.keys(value).every((key) => required.includes(key) || optional.includes(key)));
|
|
54
|
+
}
|
|
55
|
+
export function registerConsoleBrowserBridge(app, options) {
|
|
56
|
+
const registry = new Map();
|
|
57
|
+
app.addHook("onResponse", (request, reply, done) => {
|
|
58
|
+
if (options.tuttiHome && request.url.startsWith(`${BASE}/`)) {
|
|
59
|
+
const suffix = request.url.slice(BASE.length);
|
|
60
|
+
const stage = [
|
|
61
|
+
"/session",
|
|
62
|
+
"/account",
|
|
63
|
+
"/account/complete",
|
|
64
|
+
"/request",
|
|
65
|
+
"/disconnect",
|
|
66
|
+
].includes(suffix)
|
|
67
|
+
? suffix.slice(1).replaceAll("/", "_")
|
|
68
|
+
: "rejected";
|
|
69
|
+
try {
|
|
70
|
+
appendHostLogLine(getHostLogFilePath(options.tuttiHome), serializeRuntimeLogLine({
|
|
71
|
+
service: "host",
|
|
72
|
+
component: "console-bridge",
|
|
73
|
+
level: reply.statusCode >= 400 ? "warn" : "info",
|
|
74
|
+
message: "Console bridge request",
|
|
75
|
+
now: options.now ?? (() => new Date()),
|
|
76
|
+
fields: { request_id: request.id, stage, status: reply.statusCode },
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* Diagnostic failure never changes access or reveals request data. */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
done();
|
|
84
|
+
});
|
|
85
|
+
const now = () => (options.now?.() ?? new Date()).getTime();
|
|
86
|
+
const remove = (key, entry) => {
|
|
87
|
+
options.sessions.revokeSession(entry.session);
|
|
88
|
+
if (entry.continuation)
|
|
89
|
+
options.sessions.revokeAccessToken(entry.continuation);
|
|
90
|
+
registry.delete(key);
|
|
91
|
+
};
|
|
92
|
+
const purge = () => {
|
|
93
|
+
for (const [key, entry] of registry) {
|
|
94
|
+
if (entry.expires <= now() || entry.touched + IDLE_MS <= now())
|
|
95
|
+
remove(key, entry);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const fail = (request, reply, status, code) => reply.code(status).send({
|
|
99
|
+
error: {
|
|
100
|
+
code,
|
|
101
|
+
message: "Reconnect this computer from Projects.",
|
|
102
|
+
retryable: false,
|
|
103
|
+
request_id: request.id,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
// A preflight is only a transport negotiation. Every actual request separately authenticates
|
|
107
|
+
// its native-configured origin and entry capability; a successful OPTIONS grants no access.
|
|
108
|
+
app.addHook("onRequest", (request, reply, done) => {
|
|
109
|
+
if (!request.url.startsWith(`${BASE}/`))
|
|
110
|
+
return done();
|
|
111
|
+
reply.header("cache-control", "no-store").header("vary", "Origin");
|
|
112
|
+
if (!validOrigin(request.headers.origin)) {
|
|
113
|
+
void fail(request, reply, 403, "bridge_origin_rejected");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (request.url.includes("?")) {
|
|
117
|
+
void fail(request, reply, 400, "bridge_input_invalid");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
reply
|
|
121
|
+
.header("access-control-allow-origin", request.headers.origin)
|
|
122
|
+
.header("access-control-expose-headers", "x-tutti-request-id");
|
|
123
|
+
if (request.method === "OPTIONS") {
|
|
124
|
+
const requested = String(request.headers["access-control-request-headers"] ?? "")
|
|
125
|
+
.toLowerCase()
|
|
126
|
+
.split(",")
|
|
127
|
+
.map((v) => v.trim())
|
|
128
|
+
.filter(Boolean);
|
|
129
|
+
if (request.headers["access-control-request-method"] !== "POST" ||
|
|
130
|
+
requested.some((v) => !["authorization", "content-type"].includes(v))) {
|
|
131
|
+
void fail(request, reply, 403, "bridge_preflight_rejected");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
reply
|
|
135
|
+
.header("access-control-allow-methods", "POST")
|
|
136
|
+
.header("access-control-allow-headers", "authorization, content-type")
|
|
137
|
+
.header("access-control-allow-private-network", "true");
|
|
138
|
+
void reply.code(204).send();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
done();
|
|
142
|
+
});
|
|
143
|
+
app.options(`${BASE}/*`, (_request, reply) => reply.code(204).send());
|
|
144
|
+
app.post(`${BASE}/session`, (request, reply) => {
|
|
145
|
+
purge();
|
|
146
|
+
const body = request.body;
|
|
147
|
+
if (!objectWithKeys(body, ["access_token"]) ||
|
|
148
|
+
typeof body.access_token !== "string" ||
|
|
149
|
+
!tokenPattern.test(body.access_token))
|
|
150
|
+
return fail(request, reply, 400, "bridge_input_invalid");
|
|
151
|
+
const context = options.sessions.readEntryContext(body.access_token);
|
|
152
|
+
const origin = request.headers.origin;
|
|
153
|
+
const configured = context ? resolveRelayUrl(context.environment).replace(/\/$/u, "") : null;
|
|
154
|
+
if (!context || !validOrigin(origin) || configured !== origin)
|
|
155
|
+
return fail(request, reply, 403, "bridge_entry_rejected");
|
|
156
|
+
if (registry.size >= 16)
|
|
157
|
+
return fail(request, reply, 429, "bridge_capacity_reached");
|
|
158
|
+
const session = options.sessions.exchangeAccessToken(body.access_token, undefined, undefined, true);
|
|
159
|
+
if (!session?.sessionToken)
|
|
160
|
+
return fail(request, reply, 401, "bridge_entry_expired");
|
|
161
|
+
const token = secret();
|
|
162
|
+
registry.set(hash(token), {
|
|
163
|
+
origin,
|
|
164
|
+
session: session.sessionToken,
|
|
165
|
+
context: session.contextToken,
|
|
166
|
+
csrf: session.csrfToken,
|
|
167
|
+
touched: now(),
|
|
168
|
+
expires: now() + LIFETIME_MS,
|
|
169
|
+
});
|
|
170
|
+
return { bridge_token: token };
|
|
171
|
+
});
|
|
172
|
+
function authenticate(request) {
|
|
173
|
+
purge();
|
|
174
|
+
const bearer = /^Bearer ([A-Za-z0-9_-]{43})$/u.exec(String(request.headers.authorization ?? ""))?.[1];
|
|
175
|
+
const key = bearer ? hash(bearer) : "";
|
|
176
|
+
const entry = registry.get(key);
|
|
177
|
+
if (!entry ||
|
|
178
|
+
entry.origin !== request.headers.origin ||
|
|
179
|
+
!options.sessions.readSession(entry.session, entry.context))
|
|
180
|
+
return null;
|
|
181
|
+
entry.touched = now();
|
|
182
|
+
return [key, entry];
|
|
183
|
+
}
|
|
184
|
+
app.post(`${BASE}/disconnect`, (request, reply) => {
|
|
185
|
+
const auth = authenticate(request);
|
|
186
|
+
if (!auth)
|
|
187
|
+
return fail(request, reply, 401, "bridge_session_required");
|
|
188
|
+
if (request.body !== undefined)
|
|
189
|
+
return fail(request, reply, 400, "bridge_input_invalid");
|
|
190
|
+
remove(...auth);
|
|
191
|
+
return { status: "disconnected" };
|
|
192
|
+
});
|
|
193
|
+
app.post(`${BASE}/account`, (request, reply) => {
|
|
194
|
+
const auth = authenticate(request);
|
|
195
|
+
if (!auth)
|
|
196
|
+
return fail(request, reply, 401, "bridge_session_required");
|
|
197
|
+
if (request.body !== undefined)
|
|
198
|
+
return fail(request, reply, 400, "bridge_input_invalid");
|
|
199
|
+
const entry = auth[1];
|
|
200
|
+
const session = options.sessions.readSession(entry.session, entry.context);
|
|
201
|
+
if (session.accountReady)
|
|
202
|
+
return fail(request, reply, 409, "bridge_account_already_ready");
|
|
203
|
+
if (entry.continuation)
|
|
204
|
+
options.sessions.revokeAccessToken(entry.continuation);
|
|
205
|
+
const issued = options.sessions.issueAccountContinuation(session.context);
|
|
206
|
+
entry.continuation = issued.token;
|
|
207
|
+
return { access_token: issued.token, expires_at: issued.expires_at };
|
|
208
|
+
});
|
|
209
|
+
app.post(`${BASE}/account/complete`, (request, reply) => {
|
|
210
|
+
const auth = authenticate(request);
|
|
211
|
+
if (!auth)
|
|
212
|
+
return fail(request, reply, 401, "bridge_session_required");
|
|
213
|
+
const entry = auth[1];
|
|
214
|
+
const body = request.body;
|
|
215
|
+
if (!objectWithKeys(body, ["access_token", "account_display_name"]) ||
|
|
216
|
+
body.access_token !== entry.continuation ||
|
|
217
|
+
typeof body.access_token !== "string" ||
|
|
218
|
+
typeof body.account_display_name !== "string" ||
|
|
219
|
+
!body.account_display_name.trim() ||
|
|
220
|
+
body.account_display_name.length > 80)
|
|
221
|
+
return fail(request, reply, 400, "bridge_account_mismatch");
|
|
222
|
+
const session = options.sessions.exchangeAccessToken(body.access_token, undefined, body.account_display_name, true);
|
|
223
|
+
if (!session?.sessionToken)
|
|
224
|
+
return fail(request, reply, 401, "bridge_account_expired");
|
|
225
|
+
options.sessions.revokeSession(entry.session);
|
|
226
|
+
entry.session = session.sessionToken;
|
|
227
|
+
entry.context = session.contextToken;
|
|
228
|
+
entry.csrf = session.csrfToken;
|
|
229
|
+
delete entry.continuation;
|
|
230
|
+
return { status: "ready" };
|
|
231
|
+
});
|
|
232
|
+
app.post(`${BASE}/request`, async (request, reply) => {
|
|
233
|
+
const auth = authenticate(request);
|
|
234
|
+
if (!auth)
|
|
235
|
+
return fail(request, reply, 401, "bridge_session_required");
|
|
236
|
+
const [key, entry] = auth;
|
|
237
|
+
const body = request.body;
|
|
238
|
+
if (!objectWithKeys(body, ["method", "path"], ["body"]) ||
|
|
239
|
+
typeof body.method !== "string" ||
|
|
240
|
+
typeof body.path !== "string" ||
|
|
241
|
+
!isConsoleBridgeOperation(body.method, body.path) ||
|
|
242
|
+
(body.body !== undefined && typeof body.body !== "string") ||
|
|
243
|
+
(body.method === "GET" && body.body !== undefined))
|
|
244
|
+
return fail(request, reply, 400, "bridge_operation_rejected");
|
|
245
|
+
if (!options.sessions.readSession(entry.session, entry.context)?.accountReady)
|
|
246
|
+
return fail(request, reply, 401, "bridge_account_required");
|
|
247
|
+
const response = await app.inject({
|
|
248
|
+
method: body.method,
|
|
249
|
+
url: `/local-console/v1${body.path}`,
|
|
250
|
+
headers: {
|
|
251
|
+
host: request.headers.host,
|
|
252
|
+
origin: `http://${request.headers.host}`,
|
|
253
|
+
cookie: `${LOCAL_CONSOLE_SESSION_COOKIE}=${entry.session}`,
|
|
254
|
+
"x-local-console-context": entry.context,
|
|
255
|
+
"x-local-console-csrf": entry.csrf,
|
|
256
|
+
...(body.body === undefined ? {} : { "content-type": "application/json" }),
|
|
257
|
+
},
|
|
258
|
+
...(body.body === undefined ? {} : { payload: body.body }),
|
|
259
|
+
});
|
|
260
|
+
purge();
|
|
261
|
+
if (registry.get(key) !== entry)
|
|
262
|
+
return fail(request, reply, 401, "bridge_session_required");
|
|
263
|
+
if (response.rawPayload.length > 2 * 1024 * 1024)
|
|
264
|
+
return fail(request, reply, 502, "bridge_response_too_large");
|
|
265
|
+
let data;
|
|
266
|
+
try {
|
|
267
|
+
data = response.json();
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return fail(request, reply, 502, "bridge_response_invalid");
|
|
271
|
+
}
|
|
272
|
+
// The reused controller only needs a readiness marker; never expose native cookie/CSRF.
|
|
273
|
+
if (body.path === "/bootstrap" &&
|
|
274
|
+
typeof data === "object" &&
|
|
275
|
+
data !== null &&
|
|
276
|
+
"csrf_token" in data)
|
|
277
|
+
data = { ...data, csrf_token: "bridge" };
|
|
278
|
+
return reply.code(response.statusCode).send(data);
|
|
279
|
+
});
|
|
280
|
+
app.addHook("onClose", (_instance, done) => {
|
|
281
|
+
for (const [key, entry] of registry)
|
|
282
|
+
remove(key, entry);
|
|
283
|
+
done();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
//# sourceMappingURL=browser-bridge.js.map
|
|
@@ -14,6 +14,7 @@ const ReadSchema = Type.Object({ request_ref: IdempotencyKeySchema }, { addition
|
|
|
14
14
|
const ConfirmSchema = Type.Object({ request_ref: IdempotencyKeySchema, trial_consent: Type.Literal(PROJECT_TRIAL_POLICY) }, { additionalProperties: false });
|
|
15
15
|
export function registerLocalConsoleManagedProjectRoutes(app, options) {
|
|
16
16
|
for (const operation of [
|
|
17
|
+
"usage",
|
|
17
18
|
"prepare",
|
|
18
19
|
"authorize",
|
|
19
20
|
"read",
|
|
@@ -21,7 +22,9 @@ export function registerLocalConsoleManagedProjectRoutes(app, options) {
|
|
|
21
22
|
"validate",
|
|
22
23
|
"launch",
|
|
23
24
|
]) {
|
|
24
|
-
app.post(
|
|
25
|
+
app.post(operation === "usage"
|
|
26
|
+
? "/local-console/v1/account/managed-usage"
|
|
27
|
+
: `/local-console/v1/managed-projects/${operation}`, {
|
|
25
28
|
bodyLimit: 8192,
|
|
26
29
|
errorHandler: (error, request, reply) => {
|
|
27
30
|
if (error.code !== "FST_ERR_CTP_INVALID_JSON_BODY" &&
|
|
@@ -69,7 +72,16 @@ export function registerLocalConsoleManagedProjectRoutes(app, options) {
|
|
|
69
72
|
if (Object.keys(request.query).length)
|
|
70
73
|
throw new ManagedLocalStateError("managed_creation_input_invalid");
|
|
71
74
|
let result;
|
|
72
|
-
if (operation === "
|
|
75
|
+
if (operation === "usage") {
|
|
76
|
+
if (!Value.Check(Type.Object({}, { additionalProperties: false }), request.body))
|
|
77
|
+
throw new ManagedLocalStateError("managed_creation_input_invalid");
|
|
78
|
+
result = await options.service.readAccountUsage(session.context, session.accountHandoff?.token);
|
|
79
|
+
const current = options.requireSession(request, true);
|
|
80
|
+
if (!current.accountReady ||
|
|
81
|
+
current.accountHandoff?.token !== session.accountHandoff?.token)
|
|
82
|
+
throw new ManagedLocalStateError("console_setup_access_invalid");
|
|
83
|
+
}
|
|
84
|
+
else if (operation === "prepare") {
|
|
73
85
|
if (!Value.Check(ManagedDraftInputSchema, request.body))
|
|
74
86
|
throw new ManagedLocalStateError("managed_creation_input_invalid");
|
|
75
87
|
result = await options.service.prepare(request.body, session.context);
|
|
@@ -75,6 +75,16 @@ export declare class LocalConsoleManagedProjectService {
|
|
|
75
75
|
trial_consent: "trial-project-v1";
|
|
76
76
|
};
|
|
77
77
|
};
|
|
78
|
+
readAccountUsage(context: LocalConsoleInvocationContext, consoleToken?: string): Promise<{
|
|
79
|
+
state: "not_initialized";
|
|
80
|
+
unit: "newapi_quota";
|
|
81
|
+
} | {
|
|
82
|
+
state: "ready";
|
|
83
|
+
unit: "newapi_quota";
|
|
84
|
+
remaining: number;
|
|
85
|
+
used: number;
|
|
86
|
+
request_count: number;
|
|
87
|
+
}>;
|
|
78
88
|
authorize(requestRef: string, context: LocalConsoleInvocationContext, consoleToken?: string): Promise<{
|
|
79
89
|
draft: {
|
|
80
90
|
state: "validation_failed" | "prepared" | "registered" | "validated" | "awaiting_authorization" | "validation_unknown";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Value } from "@sinclair/typebox/value";
|
|
2
|
-
import { prepareNativeConsoleAuthority } from "@tutti/relay-client";
|
|
2
|
+
import { prepareNativeConsoleAuthority, readNativeConsoleAccountUsage } from "@tutti/relay-client";
|
|
3
3
|
import { Type } from "@sinclair/typebox";
|
|
4
4
|
import { IdempotencyKeySchema, ProjectIdSchema } from "@tutti/shared/schemas/domain";
|
|
5
5
|
import { resolveLaunchLocalContext } from "../cli/launch.js";
|
|
@@ -109,6 +109,16 @@ export class LocalConsoleManagedProjectService {
|
|
|
109
109
|
preparation: state.preparationBody(requestRef),
|
|
110
110
|
};
|
|
111
111
|
}
|
|
112
|
+
readAccountUsage(context, consoleToken) {
|
|
113
|
+
if (!consoleToken)
|
|
114
|
+
throw new ManagedLocalStateError("console_setup_access_invalid");
|
|
115
|
+
// Reading credit creates no project, journal, gateway account or credential.
|
|
116
|
+
return readNativeConsoleAccountUsage({
|
|
117
|
+
relayUrl: this.environment(context).TUTTI_RELAY_URL ?? "https://tutti.now",
|
|
118
|
+
consoleToken,
|
|
119
|
+
...(this.options.fetchImpl === undefined ? {} : { fetchImpl: this.options.fetchImpl }),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
112
122
|
async authorize(requestRef, context, consoleToken) {
|
|
113
123
|
return this.options.operations.runProjectOperation(async () => {
|
|
114
124
|
if (!consoleToken)
|
|
@@ -18,6 +18,7 @@ export type LocalConsoleProject = {
|
|
|
18
18
|
provider_status: ProviderConfigProjection["status"];
|
|
19
19
|
provider_model?: string;
|
|
20
20
|
relay_project_ref?: RelayProjectRef;
|
|
21
|
+
relay_origin?: string;
|
|
21
22
|
open_url?: string;
|
|
22
23
|
relay_connection_status?: string;
|
|
23
24
|
created_at?: string;
|
|
@@ -91,6 +91,7 @@ function validateProjectMetadata(input) {
|
|
|
91
91
|
}
|
|
92
92
|
function projectFromRuntimeRow(row, providerStatus, providerModel, metadata) {
|
|
93
93
|
const currentVersion = readCliVersion();
|
|
94
|
+
const relayOrigin = safeRelayOrigin(metadata.relayUrl);
|
|
94
95
|
const openUrl = metadata.surfaceProfile.kind === "standalone"
|
|
95
96
|
? projectOpenUrl(metadata.relayUrl, row.relay_project_ref)
|
|
96
97
|
: undefined;
|
|
@@ -103,6 +104,7 @@ function projectFromRuntimeRow(row, providerStatus, providerModel, metadata) {
|
|
|
103
104
|
provider_status: providerStatus,
|
|
104
105
|
...(providerModel === undefined ? {} : { provider_model: providerModel }),
|
|
105
106
|
...(row.relay_project_ref === undefined ? {} : { relay_project_ref: row.relay_project_ref }),
|
|
107
|
+
...(relayOrigin === undefined ? {} : { relay_origin: relayOrigin }),
|
|
106
108
|
...(openUrl === undefined ? {} : { open_url: openUrl }),
|
|
107
109
|
...(row.relay_connection_status === undefined
|
|
108
110
|
? {}
|
|
@@ -115,6 +117,17 @@ function projectFromRuntimeRow(row, providerStatus, providerModel, metadata) {
|
|
|
115
117
|
: { update_required: true }),
|
|
116
118
|
};
|
|
117
119
|
}
|
|
120
|
+
function safeRelayOrigin(value) {
|
|
121
|
+
try {
|
|
122
|
+
const url = new URL(value ?? "");
|
|
123
|
+
return !url.username && !url.password && (url.protocol === "https:" || url.protocol === "http:")
|
|
124
|
+
? url.origin
|
|
125
|
+
: undefined;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
118
131
|
function projectOpenUrl(relayUrl, relayProjectRef) {
|
|
119
132
|
if (relayUrl === undefined || relayProjectRef === undefined) {
|
|
120
133
|
return undefined;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { registerConsoleBrowserBridge } from "./browser-bridge.js";
|
|
2
3
|
import { LocalConsoleManagedProjectService } from "./managed-project-service.js";
|
|
3
4
|
import { registerLocalConsoleManagedProjectRoutes } from "./managed-project-routes.js";
|
|
4
5
|
import { existsSync, statSync } from "node:fs";
|
|
@@ -193,6 +194,11 @@ export function createLocalConsoleServer(options) {
|
|
|
193
194
|
...(options.now === undefined ? {} : { now: options.now }),
|
|
194
195
|
}),
|
|
195
196
|
});
|
|
197
|
+
registerConsoleBrowserBridge(app, {
|
|
198
|
+
sessions,
|
|
199
|
+
tuttiHome,
|
|
200
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
201
|
+
});
|
|
196
202
|
app.get(`${LOCAL_CONSOLE_API_BASE}/health`, () => ({
|
|
197
203
|
status: "ready",
|
|
198
204
|
protocol_version: 2,
|
|
@@ -27,7 +27,11 @@ export declare class LocalConsoleSessionRegistry {
|
|
|
27
27
|
});
|
|
28
28
|
issueAccessToken(context: LocalConsoleInvocationContext): IssuedLocalConsoleAccessToken;
|
|
29
29
|
issueAccountContinuation(context: LocalConsoleInvocationContext): IssuedLocalConsoleAccessToken;
|
|
30
|
-
exchangeAccessToken(token: string, existingSessionToken?: string, accountDisplayName?: string): ExchangedLocalConsoleSession | null;
|
|
30
|
+
exchangeAccessToken(token: string, existingSessionToken?: string, accountDisplayName?: string, isolated?: boolean): ExchangedLocalConsoleSession | null;
|
|
31
|
+
/** Inspect only a fresh native entry, never an account-ready continuation. */
|
|
32
|
+
readEntryContext(token: string): LocalConsoleInvocationContext | null;
|
|
33
|
+
revokeAccessToken(token: string): void;
|
|
34
|
+
revokeSession(token: string): void;
|
|
31
35
|
readSession(token: string | undefined, contextToken?: string): ReadLocalConsoleSession | null;
|
|
32
36
|
}
|
|
33
37
|
export declare function parseCookieHeader(header: string | undefined): Record<string, string>;
|
|
@@ -55,7 +55,7 @@ export class LocalConsoleSessionRegistry {
|
|
|
55
55
|
});
|
|
56
56
|
return { token, expires_at: new Date(expiresAt).toISOString() };
|
|
57
57
|
}
|
|
58
|
-
exchangeAccessToken(token, existingSessionToken, accountDisplayName) {
|
|
58
|
+
exchangeAccessToken(token, existingSessionToken, accountDisplayName, isolated = false) {
|
|
59
59
|
this.#purgeExpired();
|
|
60
60
|
const accessTokenHash = hashSecret(token);
|
|
61
61
|
const accessToken = this.#accessTokens.get(accessTokenHash);
|
|
@@ -68,7 +68,7 @@ export class LocalConsoleSessionRegistry {
|
|
|
68
68
|
let sessionToken;
|
|
69
69
|
let sessionHash = "";
|
|
70
70
|
let session;
|
|
71
|
-
if (existingSessionToken !== undefined) {
|
|
71
|
+
if (!isolated && existingSessionToken !== undefined) {
|
|
72
72
|
sessionHash = hashSecret(existingSessionToken);
|
|
73
73
|
const existingSession = this.#sessions.get(sessionHash);
|
|
74
74
|
if (existingSession !== undefined &&
|
|
@@ -77,7 +77,7 @@ export class LocalConsoleSessionRegistry {
|
|
|
77
77
|
session = existingSession;
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
-
if (session === undefined && this.#browserSessionToken !== undefined) {
|
|
80
|
+
if (!isolated && session === undefined && this.#browserSessionToken !== undefined) {
|
|
81
81
|
const sharedSessionHash = hashSecret(this.#browserSessionToken);
|
|
82
82
|
const sharedSession = this.#sessions.get(sharedSessionHash);
|
|
83
83
|
if (sharedSession !== undefined &&
|
|
@@ -90,7 +90,8 @@ export class LocalConsoleSessionRegistry {
|
|
|
90
90
|
}
|
|
91
91
|
if (session === undefined) {
|
|
92
92
|
sessionToken = createSecret();
|
|
93
|
-
|
|
93
|
+
if (!isolated)
|
|
94
|
+
this.#browserSessionToken = sessionToken;
|
|
94
95
|
sessionHash = hashSecret(sessionToken);
|
|
95
96
|
session = {
|
|
96
97
|
hash: sessionHash,
|
|
@@ -129,6 +130,21 @@ export class LocalConsoleSessionRegistry {
|
|
|
129
130
|
expires_at: new Date(session.expiresAt).toISOString(),
|
|
130
131
|
};
|
|
131
132
|
}
|
|
133
|
+
/** Inspect only a fresh native entry, never an account-ready continuation. */
|
|
134
|
+
readEntryContext(token) {
|
|
135
|
+
this.#purgeExpired();
|
|
136
|
+
const access = this.#accessTokens.get(hashSecret(token));
|
|
137
|
+
return access && !access.accountReady && secretEquals(token, access.hash)
|
|
138
|
+
? access.context
|
|
139
|
+
: null;
|
|
140
|
+
}
|
|
141
|
+
revokeAccessToken(token) {
|
|
142
|
+
this.#accessTokens.delete(hashSecret(token));
|
|
143
|
+
}
|
|
144
|
+
revokeSession(token) {
|
|
145
|
+
this.#sessions.delete(hashSecret(token));
|
|
146
|
+
this.#purgeExpired();
|
|
147
|
+
}
|
|
132
148
|
readSession(token, contextToken) {
|
|
133
149
|
this.#purgeExpired();
|
|
134
150
|
if (token === undefined || token.trim() === "") {
|
|
@@ -21,6 +21,18 @@ export declare function prepareNativeConsoleAuthority(options: Transport & {
|
|
|
21
21
|
surface: "standalone" | "feishu";
|
|
22
22
|
creation_ref: string;
|
|
23
23
|
}>;
|
|
24
|
+
export declare function readNativeConsoleAccountUsage(options: Transport & {
|
|
25
|
+
consoleToken: string;
|
|
26
|
+
}): Promise<{
|
|
27
|
+
state: "not_initialized";
|
|
28
|
+
unit: "newapi_quota";
|
|
29
|
+
} | {
|
|
30
|
+
state: "ready";
|
|
31
|
+
remaining: number;
|
|
32
|
+
unit: "newapi_quota";
|
|
33
|
+
used: number;
|
|
34
|
+
request_count: number;
|
|
35
|
+
}>;
|
|
24
36
|
export declare function validateNativeCreationAuthority(options: Transport & {
|
|
25
37
|
body: NativeCreationProof;
|
|
26
38
|
}): Promise<{
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Value } from "@sinclair/typebox/value";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { ManagedAccountUsageSchema } from "@tutti/shared/schemas/api";
|
|
2
4
|
import { HostDelegationProjectionSchema, HostDelegationStatusBodySchema, NativeCreationProofSchema, NativeCreationStatusSchema, CreationValidationResultSchema, NativeRuntimeAuthorizationSchema, ConsoleProjectAuthoritySchema, ManagedCreationProjectionSchema, } from "@tutti/shared/schemas/internal";
|
|
3
5
|
export class NativeAuthorityTransportError extends Error {
|
|
4
6
|
code;
|
|
@@ -14,6 +16,7 @@ export class NativeAuthorityTransportError extends Error {
|
|
|
14
16
|
}
|
|
15
17
|
const safeReasons = new Set([
|
|
16
18
|
"console_setup_access_invalid",
|
|
19
|
+
"managed_usage_unavailable",
|
|
17
20
|
"project_already_registered",
|
|
18
21
|
"project_authority_conflict",
|
|
19
22
|
"command_replay_mismatch",
|
|
@@ -114,6 +117,15 @@ export async function prepareNativeConsoleAuthority(options) {
|
|
|
114
117
|
throw new NativeAuthorityTransportError("managed_authority_invalid_response");
|
|
115
118
|
return result;
|
|
116
119
|
}
|
|
120
|
+
const ConsoleUsageSchema = Type.Pick(ConsoleProjectAuthoritySchema, ["console_token"], {
|
|
121
|
+
additionalProperties: false,
|
|
122
|
+
});
|
|
123
|
+
export async function readNativeConsoleAccountUsage(options) {
|
|
124
|
+
const body = { console_token: options.consoleToken };
|
|
125
|
+
if (!Value.Check(ConsoleUsageSchema, body))
|
|
126
|
+
throw new NativeAuthorityTransportError("managed_authority_invalid_request");
|
|
127
|
+
return request(options, "/host-control/v1/managed-authority/account-usage", body, ManagedAccountUsageSchema);
|
|
128
|
+
}
|
|
117
129
|
export async function validateNativeCreationAuthority(options) {
|
|
118
130
|
if (!Value.Check(NativeCreationProofSchema, options.body))
|
|
119
131
|
throw new NativeAuthorityTransportError("managed_authority_invalid_request");
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Static } from "@sinclair/typebox";
|
|
2
|
+
export declare const AccountProjectsSchema: import("@sinclair/typebox").TObject<{
|
|
3
|
+
projects: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
4
|
+
relay_project_ref: import("@sinclair/typebox").TString;
|
|
5
|
+
display_name: import("@sinclair/typebox").TString;
|
|
6
|
+
surface: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"standalone">, import("@sinclair/typebox").TLiteral<"feishu">]>;
|
|
7
|
+
role: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"owner">, import("@sinclair/typebox").TLiteral<"member">]>;
|
|
8
|
+
host_status: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"online">, import("@sinclair/typebox").TLiteral<"offline">, import("@sinclair/typebox").TLiteral<"unknown">]>;
|
|
9
|
+
last_opened_at: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
10
|
+
}>>;
|
|
11
|
+
truncated: import("@sinclair/typebox").TBoolean;
|
|
12
|
+
}>;
|
|
13
|
+
export type AccountProjects = Static<typeof AccountProjectsSchema>;
|
|
14
|
+
//# sourceMappingURL=account-projects.d.ts.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { IsoDateTimeStringSchema, RelayProjectRefSchema } from "../domain/index.js";
|
|
3
|
+
// Account navigation metadata only. Feishu rows never confer workspace authority.
|
|
4
|
+
export const AccountProjectsSchema = Type.Object({
|
|
5
|
+
projects: Type.Array(Type.Object({
|
|
6
|
+
relay_project_ref: RelayProjectRefSchema,
|
|
7
|
+
display_name: Type.String(),
|
|
8
|
+
surface: Type.Union([Type.Literal("standalone"), Type.Literal("feishu")]),
|
|
9
|
+
role: Type.Union([Type.Literal("owner"), Type.Literal("member")]),
|
|
10
|
+
host_status: Type.Union([
|
|
11
|
+
Type.Literal("online"),
|
|
12
|
+
Type.Literal("offline"),
|
|
13
|
+
Type.Literal("unknown"),
|
|
14
|
+
]),
|
|
15
|
+
last_opened_at: Type.Optional(IsoDateTimeStringSchema),
|
|
16
|
+
}, { additionalProperties: false }), { maxItems: 100 }),
|
|
17
|
+
truncated: Type.Boolean(),
|
|
18
|
+
}, { additionalProperties: false });
|
|
19
|
+
//# sourceMappingURL=account-projects.js.map
|
|
@@ -26,4 +26,5 @@ export { ContextSyncBodySchema, ContextSyncDispositionSchema, ContextSyncPayload
|
|
|
26
26
|
export * from "./feishu-documents.js";
|
|
27
27
|
export * from "./external-references.js";
|
|
28
28
|
export * from "./feishu-connections.js";
|
|
29
|
+
export * from "./account-projects.js";
|
|
29
30
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -22,4 +22,5 @@ export { ContextSyncBodySchema, ContextSyncDispositionSchema, ContextSyncPayload
|
|
|
22
22
|
export * from "./feishu-documents.js";
|
|
23
23
|
export * from "./external-references.js";
|
|
24
24
|
export * from "./feishu-connections.js";
|
|
25
|
+
export * from "./account-projects.js";
|
|
25
26
|
//# sourceMappingURL=index.js.map
|