@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.
@@ -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(`/local-console/v1/managed-projects/${operation}`, {
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 === "prepare") {
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
- this.#browserSessionToken = sessionToken;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfey/tutti",
3
- "version": "0.1.122",
3
+ "version": "0.1.124",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",