@xfey/tutti 0.1.32 → 0.1.34

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.
Files changed (45) hide show
  1. package/README.md +5 -4
  2. package/dist/server-shell/cli/args.d.ts +10 -0
  3. package/dist/server-shell/cli/args.js +40 -1
  4. package/dist/server-shell/cli/cli.js +136 -5
  5. package/dist/server-shell/cli/errors.d.ts +1 -1
  6. package/dist/server-shell/cli/host-runtime-endpoint.d.ts +1 -0
  7. package/dist/server-shell/cli/host-runtime-endpoint.js +10 -5
  8. package/dist/server-shell/cli/host-server-runtime.js +2 -10
  9. package/dist/server-shell/cli/machine-project-inspector.d.ts +1 -0
  10. package/dist/server-shell/cli/machine-project-inspector.js +1 -0
  11. package/dist/server-shell/cli/managed-host.d.ts +1 -0
  12. package/dist/server-shell/cli/managed-host.js +6 -4
  13. package/dist/server-shell/cli/runtime-commands.d.ts +1 -0
  14. package/dist/server-shell/cli/runtime-commands.js +5 -1
  15. package/dist/server-shell/http/routes/project-api/project-timeline-projection.js +4 -4
  16. package/dist/server-shell/http/static-web.d.ts +1 -0
  17. package/dist/server-shell/http/static-web.js +14 -1
  18. package/dist/server-shell/local-console/browser-open.d.ts +5 -0
  19. package/dist/server-shell/local-console/browser-open.js +40 -0
  20. package/dist/server-shell/local-console/folder-picker.d.ts +20 -0
  21. package/dist/server-shell/local-console/folder-picker.js +78 -0
  22. package/dist/server-shell/local-console/index.d.ts +4 -0
  23. package/dist/server-shell/local-console/index.js +4 -0
  24. package/dist/server-shell/local-console/invocation-context.d.ts +21 -0
  25. package/dist/server-shell/local-console/invocation-context.js +56 -0
  26. package/dist/server-shell/local-console/lifecycle-lock.d.ts +13 -0
  27. package/dist/server-shell/local-console/lifecycle-lock.js +135 -0
  28. package/dist/server-shell/local-console/managed-console.d.ts +47 -0
  29. package/dist/server-shell/local-console/managed-console.js +312 -0
  30. package/dist/server-shell/local-console/project-service.d.ts +61 -0
  31. package/dist/server-shell/local-console/project-service.js +262 -0
  32. package/dist/server-shell/local-console/runtime-endpoint.d.ts +35 -0
  33. package/dist/server-shell/local-console/runtime-endpoint.js +90 -0
  34. package/dist/server-shell/local-console/server.d.ts +29 -0
  35. package/dist/server-shell/local-console/server.js +408 -0
  36. package/dist/server-shell/local-console/session.d.ts +28 -0
  37. package/dist/server-shell/local-console/session.js +168 -0
  38. package/package.json +1 -1
  39. package/web/assets/index-B84rQ4YJ.js +29 -0
  40. package/web/assets/index-C7RDpM1L.css +1 -0
  41. package/web/assets/tutti_avatar-BBhaZGi3.png +0 -0
  42. package/web/index.html +6 -3
  43. package/web/assets/index-17FuaN3j.js +0 -29
  44. package/web/assets/index-C3nAJcU3.css +0 -1
  45. package/web/assets/tutti_avatar-DAVuzlig.png +0 -0
@@ -0,0 +1,408 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import fastify from "fastify";
5
+ import { resolveTuttiHome } from "../../providers/openai/index.js";
6
+ import { readCliVersion } from "../cli/version.js";
7
+ import { registerHostWebStaticRoutes, resolvePackagedWebStaticRoot } from "../http/static-web.js";
8
+ import { detectLocalFolderPicker, pickLocalFolder } from "./folder-picker.js";
9
+ import { LOCAL_CONSOLE_INVOCATION_ENV_KEYS, createLocalConsoleServiceEnvironment, } from "./invocation-context.js";
10
+ import { LocalConsoleProjectError, LocalConsoleProjectService, } from "./project-service.js";
11
+ import { LOCAL_CONSOLE_SESSION_COOKIE, LocalConsoleSessionRegistry, localConsoleSessionCookie, parseCookieHeader, } from "./session.js";
12
+ import { createLocalConsoleSecret, deleteLocalConsoleRuntimeEndpoint, writeLocalConsoleRuntimeEndpoint, } from "./runtime-endpoint.js";
13
+ export const LOCAL_CONSOLE_API_BASE = "/local-console/v1";
14
+ class LocalConsoleHttpError extends Error {
15
+ statusCode;
16
+ code;
17
+ constructor(statusCode, code, message) {
18
+ super(message);
19
+ this.statusCode = statusCode;
20
+ this.code = code;
21
+ this.name = "LocalConsoleHttpError";
22
+ }
23
+ }
24
+ function isValidationError(error) {
25
+ return (typeof error === "object" &&
26
+ error !== null &&
27
+ "validation" in error &&
28
+ Array.isArray(error.validation));
29
+ }
30
+ function headerValue(value) {
31
+ return typeof value === "string" || value === undefined ? value : value[0];
32
+ }
33
+ function bearerToken(value) {
34
+ const header = headerValue(value);
35
+ const match = header === undefined ? null : /^Bearer\s+(.+)$/iu.exec(header.trim());
36
+ return match?.[1] ?? null;
37
+ }
38
+ function tokenEquals(value, expected) {
39
+ if (value === null || value.length === 0 || expected.length === 0) {
40
+ return false;
41
+ }
42
+ const actualBuffer = Buffer.from(value);
43
+ const expectedBuffer = Buffer.from(expected);
44
+ return (actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer));
45
+ }
46
+ function requestOrigin(request) {
47
+ return `http://${headerValue(request.headers.host) ?? ""}`;
48
+ }
49
+ function requireSameOrigin(request) {
50
+ const origin = headerValue(request.headers.origin);
51
+ const fetchSite = headerValue(request.headers["sec-fetch-site"]);
52
+ if (origin !== requestOrigin(request) ||
53
+ (fetchSite !== undefined && fetchSite !== "same-origin" && fetchSite !== "none")) {
54
+ throw new LocalConsoleHttpError(403, "origin_rejected", "Local Console origin was rejected.");
55
+ }
56
+ }
57
+ function requireLoopbackHost(request) {
58
+ const host = headerValue(request.headers.host) ?? "";
59
+ if (!/^127\.0\.0\.1:\d+$/u.test(host)) {
60
+ throw new LocalConsoleHttpError(400, "host_rejected", "Local Console requires 127.0.0.1.");
61
+ }
62
+ }
63
+ export function createLocalConsoleServer(options) {
64
+ const app = fastify({ logger: false, bodyLimit: 64 * 1024 });
65
+ const tuttiHome = options.tuttiHome ?? resolveTuttiHome(options.env?.TUTTI_HOME, options.cwd);
66
+ const sessions = new LocalConsoleSessionRegistry({
67
+ ...(options.now === undefined ? {} : { now: options.now }),
68
+ });
69
+ const projects = new LocalConsoleProjectService({
70
+ tuttiHome,
71
+ ...(options.env === undefined ? {} : { serviceEnvironment: options.env }),
72
+ });
73
+ app.addHook("onRequest", (request, reply, done) => {
74
+ try {
75
+ requireLoopbackHost(request);
76
+ void reply
77
+ .header("x-content-type-options", "nosniff")
78
+ .header("x-frame-options", "DENY")
79
+ .header("referrer-policy", "no-referrer")
80
+ .header("content-security-policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'");
81
+ if (request.url.startsWith(LOCAL_CONSOLE_API_BASE)) {
82
+ void reply.header("cache-control", "no-store");
83
+ }
84
+ done();
85
+ }
86
+ catch (error) {
87
+ done(error);
88
+ }
89
+ });
90
+ app.setErrorHandler((error, _request, reply) => {
91
+ const normalized = error instanceof LocalConsoleHttpError || error instanceof LocalConsoleProjectError
92
+ ? error
93
+ : isValidationError(error)
94
+ ? new LocalConsoleHttpError(400, "bad_request", "Local Console request is invalid.")
95
+ : new LocalConsoleHttpError(500, "local_console_failed", "Local Console request failed.");
96
+ void reply.status(normalized.statusCode).send({
97
+ error: {
98
+ code: normalized.code,
99
+ message: normalized.message,
100
+ retryable: normalized.statusCode >= 500,
101
+ },
102
+ });
103
+ });
104
+ function requireControl(request) {
105
+ if (!tokenEquals(bearerToken(request.headers.authorization), options.controlToken)) {
106
+ throw new LocalConsoleHttpError(401, "unauthorized", "Local Console control token is invalid.");
107
+ }
108
+ options.onActivity?.();
109
+ }
110
+ function requireSession(request, csrf = false) {
111
+ const cookie = parseCookieHeader(headerValue(request.headers.cookie));
112
+ const session = sessions.readSession(cookie[LOCAL_CONSOLE_SESSION_COOKIE], headerValue(request.headers["x-local-console-context"]));
113
+ if (session === null) {
114
+ throw new LocalConsoleHttpError(401, "session_required", "Open Tutti from the CLI again.");
115
+ }
116
+ if (csrf) {
117
+ requireSameOrigin(request);
118
+ const csrfToken = headerValue(request.headers["x-local-console-csrf"]);
119
+ if (!tokenEquals(csrfToken ?? null, session.csrfToken)) {
120
+ throw new LocalConsoleHttpError(403, "csrf_rejected", "Local Console request was rejected.");
121
+ }
122
+ }
123
+ options.onActivity?.();
124
+ return session;
125
+ }
126
+ app.get(`${LOCAL_CONSOLE_API_BASE}/health`, () => ({
127
+ status: "ready",
128
+ protocol_version: 2,
129
+ ...(options.runtime === undefined
130
+ ? {}
131
+ : {
132
+ instance_id: options.runtime.instanceId,
133
+ version: options.runtime.version,
134
+ pid: options.runtime.pid,
135
+ }),
136
+ }));
137
+ app.get(`${LOCAL_CONSOLE_API_BASE}/control/status`, (request) => {
138
+ requireControl(request);
139
+ return {
140
+ status: "running",
141
+ protocol_version: 2,
142
+ ...(options.runtime === undefined
143
+ ? {}
144
+ : {
145
+ instance_id: options.runtime.instanceId,
146
+ version: options.runtime.version,
147
+ entrypoint: options.runtime.entrypoint,
148
+ started_at: options.runtime.startedAt,
149
+ pid: options.runtime.pid,
150
+ }),
151
+ };
152
+ });
153
+ app.post(`${LOCAL_CONSOLE_API_BASE}/control/shutdown`, (request) => {
154
+ requireControl(request);
155
+ setImmediate(() => options.onShutdown?.());
156
+ return { status: "stopping" };
157
+ });
158
+ app.post(`${LOCAL_CONSOLE_API_BASE}/access-tokens`, {
159
+ schema: {
160
+ body: {
161
+ type: "object",
162
+ required: ["current_directory"],
163
+ additionalProperties: false,
164
+ properties: {
165
+ current_directory: { type: "string", minLength: 1, maxLength: 4096 },
166
+ environment: {
167
+ type: "object",
168
+ additionalProperties: false,
169
+ properties: Object.fromEntries(LOCAL_CONSOLE_INVOCATION_ENV_KEYS.map((key) => [
170
+ key,
171
+ { type: "string", minLength: 1, maxLength: 4096 },
172
+ ])),
173
+ },
174
+ },
175
+ },
176
+ },
177
+ }, (request) => {
178
+ requireControl(request);
179
+ const access = sessions.issueAccessToken({
180
+ currentDirectory: resolve(request.body.current_directory),
181
+ environment: request.body.environment ?? {},
182
+ });
183
+ return { access_token: access.token, expires_at: access.expires_at };
184
+ });
185
+ app.post(`${LOCAL_CONSOLE_API_BASE}/session`, {
186
+ schema: {
187
+ body: {
188
+ type: "object",
189
+ required: ["access_token"],
190
+ additionalProperties: false,
191
+ properties: { access_token: { type: "string", minLength: 1 } },
192
+ },
193
+ },
194
+ }, (request, reply) => {
195
+ requireSameOrigin(request);
196
+ const cookie = parseCookieHeader(headerValue(request.headers.cookie));
197
+ const session = sessions.exchangeAccessToken(request.body.access_token, cookie[LOCAL_CONSOLE_SESSION_COOKIE]);
198
+ if (session === null) {
199
+ throw new LocalConsoleHttpError(401, "access_token_invalid", "This Local Console access link has expired. Run `tutti` again.");
200
+ }
201
+ if (session.sessionToken !== undefined) {
202
+ void reply.header("set-cookie", localConsoleSessionCookie(session.sessionToken, session.expires_at));
203
+ }
204
+ options.onActivity?.();
205
+ return {
206
+ csrf_token: session.csrfToken,
207
+ context_token: session.contextToken,
208
+ expires_at: session.expires_at,
209
+ };
210
+ });
211
+ app.get(`${LOCAL_CONSOLE_API_BASE}/bootstrap`, (request) => {
212
+ const session = requireSession(request);
213
+ return {
214
+ csrf_token: session.csrfToken,
215
+ current_directory: session.context.currentDirectory,
216
+ platform: process.platform,
217
+ folder_picker: detectLocalFolderPicker(process.platform, session.context.environment).kind,
218
+ };
219
+ });
220
+ app.get(`${LOCAL_CONSOLE_API_BASE}/projects`, async (request) => {
221
+ const session = requireSession(request);
222
+ return { projects: await projects.listProjects(session.context) };
223
+ });
224
+ app.get(`${LOCAL_CONSOLE_API_BASE}/heartbeat`, (request) => {
225
+ requireSession(request);
226
+ return { status: "ok" };
227
+ });
228
+ app.post(`${LOCAL_CONSOLE_API_BASE}/folders/pick`, async (request) => {
229
+ const session = requireSession(request, true);
230
+ return await pickLocalFolder({ env: session.context.environment });
231
+ });
232
+ app.post(`${LOCAL_CONSOLE_API_BASE}/providers/models`, {
233
+ schema: {
234
+ body: {
235
+ type: "object",
236
+ required: ["base_url", "api_key"],
237
+ additionalProperties: false,
238
+ properties: {
239
+ base_url: { type: "string", minLength: 1, maxLength: 2048 },
240
+ api_key: { type: "string", minLength: 1, maxLength: 4096 },
241
+ },
242
+ },
243
+ },
244
+ }, async (request) => {
245
+ requireSession(request, true);
246
+ return await projects.discoverModels({
247
+ baseUrl: request.body.base_url,
248
+ apiKey: request.body.api_key,
249
+ });
250
+ });
251
+ app.post(`${LOCAL_CONSOLE_API_BASE}/projects/launch`, {
252
+ schema: {
253
+ body: {
254
+ type: "object",
255
+ required: ["workspace_path"],
256
+ additionalProperties: false,
257
+ properties: {
258
+ workspace_path: { type: "string", minLength: 1, maxLength: 4096 },
259
+ provider: {
260
+ type: "object",
261
+ required: ["base_url", "api_key", "model"],
262
+ additionalProperties: false,
263
+ properties: {
264
+ base_url: { type: "string", minLength: 1, maxLength: 2048 },
265
+ api_key: { type: "string", minLength: 1, maxLength: 4096 },
266
+ model: { type: "string", minLength: 1, maxLength: 240 },
267
+ },
268
+ },
269
+ },
270
+ },
271
+ },
272
+ }, async (request) => {
273
+ const session = requireSession(request, true);
274
+ return await projects.launchProject({
275
+ workspacePath: request.body.workspace_path,
276
+ ...(request.body.provider === undefined ? {} : { provider: request.body.provider }),
277
+ }, session.context);
278
+ });
279
+ app.post(`${LOCAL_CONSOLE_API_BASE}/projects/:projectId/invite`, async (request) => {
280
+ const session = requireSession(request, true);
281
+ return await projects.refreshInvite(request.params.projectId, session.context);
282
+ });
283
+ app.post(`${LOCAL_CONSOLE_API_BASE}/projects/:projectId/stop`, async (request) => {
284
+ const session = requireSession(request, true);
285
+ return await projects.stopProject(request.params.projectId, session.context);
286
+ });
287
+ const webStaticRoot = options.webStaticRoot === false
288
+ ? undefined
289
+ : (options.webStaticRoot ?? resolvePackagedWebStaticRoot(options.env));
290
+ if (webStaticRoot !== undefined) {
291
+ registerHostWebStaticRoutes(app, { root: webStaticRoot });
292
+ }
293
+ return app;
294
+ }
295
+ function listeningPort(app) {
296
+ const address = app.server.address();
297
+ if (typeof address !== "object" || address === null) {
298
+ throw new Error("Local Console did not expose a loopback port.");
299
+ }
300
+ return address.port;
301
+ }
302
+ function entrypointIdentity(path) {
303
+ try {
304
+ const stats = statSync(path);
305
+ return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}`;
306
+ }
307
+ catch {
308
+ return null;
309
+ }
310
+ }
311
+ export async function runLocalConsoleProcess(options = {}) {
312
+ const cwd = resolve(options.cwd ?? process.cwd());
313
+ const env = options.env ?? process.env;
314
+ const tuttiHome = resolveTuttiHome(env.TUTTI_HOME, cwd);
315
+ const serviceEnvironment = createLocalConsoleServiceEnvironment({ env, tuttiHome });
316
+ const controlToken = createLocalConsoleSecret();
317
+ const instanceId = createLocalConsoleSecret();
318
+ const now = options.now ?? (() => new Date());
319
+ const startedAt = now().toISOString();
320
+ const version = readCliVersion();
321
+ const entrypointArgument = process.argv[1];
322
+ if (entrypointArgument === undefined || entrypointArgument.trim() === "") {
323
+ throw new Error("Cannot locate the Tutti CLI entrypoint.");
324
+ }
325
+ const entrypoint = resolve(entrypointArgument);
326
+ const initialEntrypointIdentity = entrypointIdentity(entrypoint);
327
+ if (initialEntrypointIdentity === null) {
328
+ throw new Error("The Tutti CLI entrypoint is unavailable.");
329
+ }
330
+ let lastActivityAt = now().getTime();
331
+ let requestClose = () => undefined;
332
+ const app = createLocalConsoleServer({
333
+ controlToken,
334
+ cwd,
335
+ tuttiHome,
336
+ env: serviceEnvironment,
337
+ runtime: {
338
+ instanceId,
339
+ version,
340
+ entrypoint,
341
+ startedAt,
342
+ pid: process.pid,
343
+ },
344
+ onActivity: () => {
345
+ lastActivityAt = now().getTime();
346
+ },
347
+ onShutdown: () => requestClose(),
348
+ ...(options.now === undefined ? {} : { now: options.now }),
349
+ });
350
+ await app.listen({ host: "127.0.0.1", port: 0 });
351
+ writeLocalConsoleRuntimeEndpoint({
352
+ tuttiHome,
353
+ instanceId,
354
+ pid: process.pid,
355
+ baseUrl: `http://127.0.0.1:${listeningPort(app)}`,
356
+ controlToken,
357
+ version,
358
+ entrypoint,
359
+ now,
360
+ });
361
+ let closing = false;
362
+ const close = async () => {
363
+ if (closing) {
364
+ return;
365
+ }
366
+ closing = true;
367
+ deleteLocalConsoleRuntimeEndpoint({
368
+ tuttiHome,
369
+ expectedControlToken: controlToken,
370
+ });
371
+ const forceClose = setTimeout(() => app.server.closeAllConnections(), 5_000);
372
+ forceClose.unref();
373
+ try {
374
+ await app.close();
375
+ }
376
+ finally {
377
+ clearTimeout(forceClose);
378
+ }
379
+ };
380
+ requestClose = () => void close();
381
+ const idleTimeoutMs = options.idleTimeoutMs ?? 60 * 60 * 1_000;
382
+ const idleCheck = setInterval(() => {
383
+ let installedVersion = null;
384
+ try {
385
+ installedVersion = readCliVersion();
386
+ }
387
+ catch {
388
+ // Package removal is handled as a runtime identity change.
389
+ }
390
+ if (!existsSync(entrypoint) ||
391
+ entrypointIdentity(entrypoint) !== initialEntrypointIdentity ||
392
+ installedVersion !== version) {
393
+ void close();
394
+ return;
395
+ }
396
+ if (now().getTime() - lastActivityAt >= idleTimeoutMs) {
397
+ void close();
398
+ }
399
+ }, Math.min(60_000, Math.max(1_000, Math.floor(idleTimeoutMs / 4))));
400
+ idleCheck.unref();
401
+ process.once("SIGINT", () => void close());
402
+ process.once("SIGTERM", () => void close());
403
+ await new Promise((resolveClosed) => {
404
+ app.server.once("close", resolveClosed);
405
+ });
406
+ clearInterval(idleCheck);
407
+ }
408
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,28 @@
1
+ import type { LocalConsoleInvocationContext } from "./invocation-context.js";
2
+ export declare const LOCAL_CONSOLE_SESSION_COOKIE = "tutti_local_console_session";
3
+ export type IssuedLocalConsoleAccessToken = {
4
+ token: string;
5
+ expires_at: string;
6
+ };
7
+ export type ExchangedLocalConsoleSession = {
8
+ sessionToken?: string;
9
+ contextToken: string;
10
+ csrfToken: string;
11
+ expires_at: string;
12
+ };
13
+ export type ReadLocalConsoleSession = {
14
+ csrfToken: string;
15
+ context: LocalConsoleInvocationContext;
16
+ };
17
+ export declare class LocalConsoleSessionRegistry {
18
+ #private;
19
+ constructor(options?: {
20
+ now?: () => Date;
21
+ });
22
+ issueAccessToken(context: LocalConsoleInvocationContext): IssuedLocalConsoleAccessToken;
23
+ exchangeAccessToken(token: string, existingSessionToken?: string): ExchangedLocalConsoleSession | null;
24
+ readSession(token: string | undefined, contextToken?: string): ReadLocalConsoleSession | null;
25
+ }
26
+ export declare function parseCookieHeader(header: string | undefined): Record<string, string>;
27
+ export declare function localConsoleSessionCookie(token: string, expiresAt: string): string;
28
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1,168 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ export const LOCAL_CONSOLE_SESSION_COOKIE = "tutti_local_console_session";
3
+ const ACCESS_TOKEN_TTL_MS = 60_000;
4
+ const SESSION_TTL_MS = 12 * 60 * 60 * 1_000;
5
+ function createSecret() {
6
+ return randomBytes(32).toString("base64url");
7
+ }
8
+ function hashSecret(value) {
9
+ return createHash("sha256").update(value).digest("base64url");
10
+ }
11
+ function secretEquals(value, expectedHash) {
12
+ const actual = Buffer.from(hashSecret(value));
13
+ const expected = Buffer.from(expectedHash);
14
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
15
+ }
16
+ export class LocalConsoleSessionRegistry {
17
+ #accessTokens = new Map();
18
+ #sessions = new Map();
19
+ #contexts = new Map();
20
+ #now;
21
+ #browserSessionToken;
22
+ constructor(options = {}) {
23
+ this.#now = options.now ?? (() => new Date());
24
+ }
25
+ issueAccessToken(context) {
26
+ this.#purgeExpired();
27
+ const token = createSecret();
28
+ const expiresAt = this.#now().getTime() + ACCESS_TOKEN_TTL_MS;
29
+ this.#accessTokens.set(hashSecret(token), {
30
+ hash: hashSecret(token),
31
+ expiresAt,
32
+ context,
33
+ });
34
+ return { token, expires_at: new Date(expiresAt).toISOString() };
35
+ }
36
+ exchangeAccessToken(token, existingSessionToken) {
37
+ this.#purgeExpired();
38
+ const accessTokenHash = hashSecret(token);
39
+ const accessToken = this.#accessTokens.get(accessTokenHash);
40
+ if (accessToken === undefined ||
41
+ accessToken.expiresAt <= this.#now().getTime() ||
42
+ !secretEquals(token, accessToken.hash)) {
43
+ return null;
44
+ }
45
+ this.#accessTokens.delete(accessTokenHash);
46
+ let sessionToken;
47
+ let sessionHash = "";
48
+ let session;
49
+ if (existingSessionToken !== undefined) {
50
+ sessionHash = hashSecret(existingSessionToken);
51
+ const existingSession = this.#sessions.get(sessionHash);
52
+ if (existingSession !== undefined &&
53
+ existingSession.expiresAt > this.#now().getTime() &&
54
+ secretEquals(existingSessionToken, existingSession.hash)) {
55
+ session = existingSession;
56
+ }
57
+ }
58
+ if (session === undefined && this.#browserSessionToken !== undefined) {
59
+ const sharedSessionHash = hashSecret(this.#browserSessionToken);
60
+ const sharedSession = this.#sessions.get(sharedSessionHash);
61
+ if (sharedSession !== undefined &&
62
+ sharedSession.expiresAt > this.#now().getTime() &&
63
+ secretEquals(this.#browserSessionToken, sharedSession.hash)) {
64
+ sessionToken = this.#browserSessionToken;
65
+ sessionHash = sharedSessionHash;
66
+ session = sharedSession;
67
+ }
68
+ }
69
+ if (session === undefined) {
70
+ sessionToken = createSecret();
71
+ this.#browserSessionToken = sessionToken;
72
+ sessionHash = hashSecret(sessionToken);
73
+ session = {
74
+ hash: sessionHash,
75
+ csrfToken: createSecret(),
76
+ expiresAt: this.#now().getTime() + SESSION_TTL_MS,
77
+ defaultContextHash: "",
78
+ };
79
+ this.#sessions.set(sessionHash, session);
80
+ }
81
+ const contextToken = createSecret();
82
+ const contextHash = hashSecret(contextToken);
83
+ session.defaultContextHash = contextHash;
84
+ this.#contexts.set(contextHash, {
85
+ hash: contextHash,
86
+ expiresAt: session.expiresAt,
87
+ sessionHash,
88
+ context: accessToken.context,
89
+ });
90
+ return {
91
+ ...(sessionToken === undefined ? {} : { sessionToken }),
92
+ contextToken,
93
+ csrfToken: session.csrfToken,
94
+ expires_at: new Date(session.expiresAt).toISOString(),
95
+ };
96
+ }
97
+ readSession(token, contextToken) {
98
+ this.#purgeExpired();
99
+ if (token === undefined || token.trim() === "") {
100
+ return null;
101
+ }
102
+ const session = this.#sessions.get(hashSecret(token));
103
+ if (session === undefined ||
104
+ session.expiresAt <= this.#now().getTime() ||
105
+ !secretEquals(token, session.hash)) {
106
+ return null;
107
+ }
108
+ const contextHash = contextToken === undefined ? session.defaultContextHash : hashSecret(contextToken);
109
+ const context = this.#contexts.get(contextHash);
110
+ if (context === undefined ||
111
+ context.sessionHash !== hashSecret(token) ||
112
+ context.expiresAt <= this.#now().getTime() ||
113
+ (contextToken !== undefined && !secretEquals(contextToken, context.hash))) {
114
+ return null;
115
+ }
116
+ return { csrfToken: session.csrfToken, context: context.context };
117
+ }
118
+ #purgeExpired() {
119
+ const now = this.#now().getTime();
120
+ for (const [key, value] of this.#accessTokens) {
121
+ if (value.expiresAt <= now) {
122
+ this.#accessTokens.delete(key);
123
+ }
124
+ }
125
+ for (const [key, value] of this.#sessions) {
126
+ if (value.expiresAt <= now) {
127
+ this.#sessions.delete(key);
128
+ }
129
+ }
130
+ if (this.#browserSessionToken !== undefined &&
131
+ !this.#sessions.has(hashSecret(this.#browserSessionToken))) {
132
+ this.#browserSessionToken = undefined;
133
+ }
134
+ for (const [key, value] of this.#contexts) {
135
+ if (value.expiresAt <= now || !this.#sessions.has(value.sessionHash)) {
136
+ this.#contexts.delete(key);
137
+ }
138
+ }
139
+ }
140
+ }
141
+ export function parseCookieHeader(header) {
142
+ if (header === undefined) {
143
+ return {};
144
+ }
145
+ const cookies = {};
146
+ for (const part of header.split(";")) {
147
+ const separator = part.indexOf("=");
148
+ if (separator <= 0) {
149
+ continue;
150
+ }
151
+ const name = part.slice(0, separator).trim();
152
+ const value = part.slice(separator + 1).trim();
153
+ if (name !== "") {
154
+ cookies[name] = value;
155
+ }
156
+ }
157
+ return cookies;
158
+ }
159
+ export function localConsoleSessionCookie(token, expiresAt) {
160
+ return [
161
+ `${LOCAL_CONSOLE_SESSION_COOKIE}=${token}`,
162
+ "Path=/",
163
+ "HttpOnly",
164
+ "SameSite=Strict",
165
+ `Expires=${new Date(expiresAt).toUTCString()}`,
166
+ ].join("; ");
167
+ }
168
+ //# sourceMappingURL=session.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfey/tutti",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",