Compare commits

...
35 Commits
Author SHA1 Message Date
tamilselvanp57 a4bb57bb79 Fix static hosting domain fallback for self-hosted custom domains (#3382) 2026-07-15 11:45:10 -07:00
Daniel Salazar 861cf5efb8 feat: email api wip (#3391) 2026-07-15 11:30:16 -07:00
Daniel Salazar cf34f9fea9 fix: sanitize get user response (#3390)
* fix: sanitize get user response

* fix: app creation in dev center
2026-07-15 02:02:06 -07:00
jelveh 92a6d30e26 Add calendar to recommended apps 2026-07-14 23:04:24 -07:00
Neal ShahandDaniel Salazar 23e8705b03 Implement workers without cloudflare for local dev testing (#3389)
* typeify subdomains wip

* initial (untested) logic for LocalWorkerService

* Make it work, add lifecycle expiry since workers are process heavy in current implementation

* fix type errors

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-07-15 01:01:42 -04:00
Daniel Salazar 3240a4670a fix: admin gates (#3386) 2026-07-14 12:58:04 -07:00
Daniel Salazar 3bf83d40e9 fix: ui signup order (#3385) 2026-07-13 18:47:11 -07:00
Neal Shah c3368f553e Add GUI button for custom OIDC (#3384) 2026-07-13 20:32:37 -04:00
velzie 27def94d8b feat: support cross-origin-isolated login (#3338) 2026-07-13 17:36:56 -04:00
Neal Shah 2c139929ac Check if user is suspended on WebDAV basic auth and others (#3369) 2026-07-13 02:46:28 -04:00
Neal Shah b957f797da add support for ndjson logging (#3381)
* add support for ndjson logging

* move install location
2026-07-13 02:43:56 -04:00
jelveh 393500d565 Add calculator to recommended apps
Include 'calculator' in RECOMMENDED_APP_NAMES within RecommendedAppsService.ts so the calculator app is included in the recommended apps list.
2026-07-11 23:04:39 -07:00
jelveh a108837e82 Revert "Add delayed centered spinner for apps tab"
This reverts commit 859e88753f.
2026-07-11 15:21:59 -07:00
jelveh 859e88753f Add delayed centered spinner for apps tab
Show a subtle centered loading spinner for the Apps dashboard when fetching apps if the request is slow. Implements a 1.5s delay before showing the spinner and guarantees a minimum 1.5s visible time to avoid blinking; the spinner is only shown when the container is empty (so background refreshes keep existing tiles). Adds a finish helper to clear the spinner timer and await the minimum visible time before rendering results or errors. Also adds CSS for the spinner element and its animations.
2026-07-11 15:16:04 -07:00
Daniel Salazar 5160b44c4b fix: missing OIDC error messages (#3377)
* fix: missing OIDC error messages

* fix: card verification user prefill out for subs
2026-07-11 08:18:13 -07:00
Daniel Salazar 17a9485fb2 Add spans to drivers and high frequency paths (#3376) 2026-07-10 23:15:09 -07:00
Daniel Salazar d3c44f3d6c fix: cache healthcheck in mem for each node (#3375) 2026-07-10 17:41:09 -07:00
Neal Shah d49ba5281d add support for ignoring specific issues in healthcheck (#3374)
* add support for ignoring specific issues in healthcheck

* add mark-degraded to healthcheck
2026-07-10 17:08:03 -04:00
jelveh c107c80bd8 Open dashboard links at #home anchor 2026-07-10 13:22:58 -07:00
Daniel Salazar cf90e775bf fingerprint: use device info for rate limit too (#3372) 2026-07-09 20:22:56 -07:00
Daniel Salazar 0a46096c41 fix: ai rate limits (#3371) 2026-07-09 16:35:03 -07:00
Daniel Salazar 289a44ae79 fix: OIDC state (#3370) 2026-07-09 14:47:34 -07:00
Neal Shah 827e9bc343 fix possible XSS entrypoints (#3368) 2026-07-09 17:19:55 -04:00
Daniel Salazar f2ffaeb823 fix: add phone errors into kv for debugging (#3367) 2026-07-09 12:25:17 -07:00
Neal Shah 6f594f4b60 add 5.6 models (#3366) 2026-07-09 14:46:57 -04:00
jelveh 326be042f1 Merge branch 'main' of https://github.com/HeyPuter/puter 2026-07-08 22:33:20 -07:00
jelveh 5b824fb745 Extract resolveTileDisplay and broaden app search
Move logic that determines a tile's displayed title and target link into resolveTileDisplay (use hostname/index_url for external apps) and have buildTileHtml consume it. Expose data-target-link on tiles. Update app search to match the displayed title, raw title, name, and uid so queries find website-shortcut tiles and external apps consistently (matching Home tab behavior).
2026-07-08 22:33:13 -07:00
Reynaldi Chernando cf6cb126b5 fix grok 4.5 release date (#3365) 2026-07-09 12:30:39 +07:00
Reynaldi ChernandoandCopilot Autofix powered by AI 302df1edd2 update user pays model docs (#3364)
* update user pays model docs

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-09 08:54:04 +07:00
jelveh e212509ed3 Avoid clipping scaled icons in horizontal scroller
Add a negative top margin and matching top padding to the horizontal scroller to prevent first-row icons from being clipped when they scale on hover. Overflow-x:auto previously caused vertical clipping of scaled icons; margin-top:-8px and padding-top:8px bleed the clip edge upward so scaled icons remain visible without changing scroll behavior.
2026-07-08 18:35:57 -07:00
Daniel Salazar d3e3755d6d chore: cleanup uneeded ai messages (#3363) 2026-07-08 17:55:49 -07:00
jelveh 948ce6e529 Merge branch 'main' of https://github.com/HeyPuter/puter 2026-07-08 17:51:38 -07:00
jelveh d117dc231e Implement paged apps launcher UI 2026-07-08 17:50:34 -07:00
Daniel Salazar eefa53e5bd fix: misc auth bug fixes (#3362) 2026-07-08 17:50:22 -07:00
Neal Shah db89fbfc52 add grok 4.5 (#3360) 2026-07-08 14:44:05 -04:00
83 changed files with 5070 additions and 1686 deletions

No files matched your search

+3 -1
View File
@@ -53,7 +53,7 @@ The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts))
- **Modules:** We transpile and build as needed — write ES modules, not CommonJS.
- **TypeScript preferred for new files.** Existing JS is fine; convert opportunistically when you're already touching a file.
- **Reuse types before inventing them.** Search for an existing type first; extend it if close. Only define a new type when nothing fits.
- **Reuse types and code before adding duplicates.** Before defining a type, search for an existing one; extend it if close. Before writing a helper or repeating logic, search for an existing utility, helper, or implementation and reuse or extend it. Only add a new type or helper when nothing suitable exists.
- **Make new types findable.** Co-locate them with the layer/module that owns them, export from the obvious entry point, and use a descriptive `PascalCase` name. Don't hide types in random files where future readers won't grep them.
- **Naming:** `camelCase` for variables/functions, `PascalCase` for classes and for files containing a class (`AuthService.ts`, `KVStoreDriver.ts`).
@@ -64,6 +64,8 @@ Keep comments light. Prefer self-documenting code — clear names, small functio
- The _why_ is non-obvious (a hidden constraint, a workaround, a subtle invariant).
- A non-trivial usage detail would otherwise trip the next reader.
If comments need to be more than a line, its probably too long, and when it does need multiline comments, use multiline `/** ... */` JSDoc style for consistency. Use `//` for single-line comments.
Don't restate what the code already says. Don't write comments that reference the current task or PR or version of something — those rot.
**No ticket references in code or test comments.** Don't write `PUT-1234`, `AUTH-3`, `GUI-2`, `// fix for FOO-99`, `// per ROLLOUT-1`, or section headers like `// -- PUT-1020: ... --`. The ticket exists in git history and the issue tracker; the code should describe the _why_ in domain terms ("logout cascade transparently invalidates...") not project-management terms ("the cascade from AUTH-3..."). Same rule for test-block names and section dividers — describe what's being tested, not which ticket asked for it.
+6
View File
@@ -16,6 +16,12 @@
// `dev` opens a browser on boot, skips blocked-email checks, and runs the
// dev-time webpack watcher; `prod` serves pre-built bundles.
"env": "dev",
// Console output format. `json` replaces the global console so every call
// emits one structured JSON line (level, timestamp, msg, and the active
// request's trace id) — one event per call, so a line-oriented log
// collector can't split stack traces across events and level filtering
// works. Unset (the default) leaves console output human-readable.
"log_format": "text",
"version": "0.0.0",
// Stable identity for this server node — used by pager alerts and
// graceful-shutdown coordination.
+43
View File
@@ -158,6 +158,49 @@ describe('whoami extension — handleWhoami', () => {
expect(body.directories).toBeUndefined();
});
it('redacts tmp_password from metadata for user actors', async () => {
const user = await seedUser();
await server.stores.user.updateMetadata(user.id as number, {
tmp_password: 'bootstrap-secret',
hasDevAccountAccess: true,
});
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleWhoami(makeReq(), res),
);
const body = captured.body as Record<string, unknown>;
const metadata = body.metadata as Record<string, unknown>;
expect(metadata.tmp_password).toBeUndefined();
// Other metadata keys still reach the user's own client.
expect(metadata.hasDevAccountAccess).toBe(true);
expect(body.hasDevAccountAccess).toBe(true);
});
it('never sends user metadata to app actors', async () => {
const user = await seedUser();
await server.stores.user.updateMetadata(user.id as number, {
tmp_password: 'bootstrap-secret',
});
const { res, captured } = makeRes();
await runWithContext(
{
actor: {
user: { uuid: user.uuid, id: user.id as number },
app: { uid: 'app-test-actor' },
},
},
() => handleWhoami(makeReq(), res),
);
const body = captured.body as Record<string, unknown>;
expect(body.metadata).toBeUndefined();
expect(JSON.stringify(body)).not.toContain('bootstrap-secret');
});
it('marks the user as oidc_only when password is null', async () => {
const slug = Math.random().toString(36).slice(2, 8);
const oidcUser = await server.stores.user.create({
+5 -1
View File
@@ -65,6 +65,9 @@ export const handleWhoami = async (
}
}
const metadata = user.metadata ? { ...user.metadata } : user.metadata;
if (metadata) delete metadata.tmp_password;
const details: Record<string, unknown> = {
username: user.username,
uuid: user.uuid,
@@ -98,7 +101,7 @@ export const handleWhoami = async (
human_readable_age: user.timestamp
? timeago.format(new Date(user.timestamp as string))
: null,
metadata: user.metadata,
metadata,
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
};
@@ -162,6 +165,7 @@ export const handleWhoami = async (
delete details.desktop_bg_fit;
delete details.human_readable_age;
delete details.is_user_token;
delete details.metadata;
}
if (actor.app) {
+11
View File
@@ -6482,6 +6482,16 @@
"form-data": "^4.0.4"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/oracledb": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@types/oracledb/-/oracledb-6.5.2.tgz",
@@ -18202,6 +18212,7 @@
"@types/busboy": "^1.5.4",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.0.0",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.6.1",
"@types/validator": "^13.15.10",
"chai": "^4.3.7",
+1
View File
@@ -78,6 +78,7 @@
"dedent": "^1.5.3",
"javascript-time-ago": "^2.5.11",
"libphonenumber-js": "1.13.6",
"miniflare": "^4.20260617.1",
"open": "^10.1.0"
},
"engines": {
@@ -20,6 +20,7 @@
import { readdirSync, readFileSync } from 'fs';
import { isAbsolute, resolve as resolvePath } from 'path';
import { createPool, type Pool } from 'mysql2';
import { Span } from '../../util/span.js';
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
import { SQLBatcher } from './SQLBatcher.js';
import { splitMysqlStatements } from './splitMysqlStatements.js';
@@ -135,6 +136,10 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
// Query interface
// ------------------------------------------------------------------
// The db.* spans measure the logical query, including time queued in
// the SQLBatcher — the mysql2 auto-instrumentation only sees the
// coalesced multi-statement flush, so per-query latency lives here.
@Span('db.read', (query: string) => ({ 'db.statement': query }))
override async read(
query: string,
params: unknown[] = [],
@@ -144,6 +149,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
return (result[0] as Record<string, unknown>[]) ?? [];
}
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
override async pread(
query: string,
params: unknown[] = [],
@@ -153,6 +159,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
return (result[0] as Record<string, unknown>[]) ?? [];
}
@Span('db.write', (query: string) => ({ 'db.statement': query }))
override async write(
query: string,
params: unknown[] = [],
@@ -170,6 +177,9 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
};
}
@Span('db.batchWrite', (entries: unknown[]) => ({
'db.batch_size': entries.length,
}))
override async batchWrite(
entries: { statement: string; values: unknown[] }[],
): Promise<void> {
@@ -195,6 +205,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
}
}
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
override async tryHardRead(
query: string,
params: unknown[] = [],
@@ -20,6 +20,7 @@
import { readdirSync, readFileSync } from 'fs';
import { isAbsolute, resolve as resolvePath } from 'path';
import { Pool, type PoolConfig, type QueryResult } from 'pg';
import { Span } from '../../util/span.js';
import {
AbstractDatabaseClient,
type BatchEntry,
@@ -214,6 +215,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
return value;
}
@Span('db.read', (query: string) => ({ 'db.statement': query }))
override async read(
query: string,
params: unknown[] = [],
@@ -222,6 +224,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
return normalizePostgresRows(result);
}
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
override async pread(
query: string,
params: unknown[] = [],
@@ -230,6 +233,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
return normalizePostgresRows(result);
}
@Span('db.write', (query: string) => ({ 'db.statement': query }))
override async write(
query: string,
params: unknown[] = [],
@@ -238,6 +242,9 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
return mapPostgresWriteResult(result);
}
@Span('db.batchWrite', (entries: unknown[]) => ({
'db.batch_size': entries.length,
}))
override async batchWrite(entries: BatchEntry[]): Promise<void> {
if (entries.length === 0) return;
@@ -258,6 +265,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
}
}
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
override async tryHardRead(
query: string,
params: unknown[] = [],
@@ -21,6 +21,7 @@ import { existsSync, mkdirSync, readFileSync } from 'fs';
import { basename, dirname, extname, join, resolve } from 'path';
import { createContext, runInContext } from 'vm';
import type { IConfig } from '../../types';
import { Span } from '../../util/span.js';
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
const MIGRATIONS_DIR = resolve(__dirname, './migrations/sqlite');
@@ -136,6 +137,7 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
// Query interface
// ------------------------------------------------------------------
@Span('db.read', (query: string) => ({ 'db.statement': query }))
override async read(
query: string,
params: unknown[] = [],
@@ -156,6 +158,7 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
return this.read(query, params);
}
@Span('db.write', (query: string) => ({ 'db.statement': query }))
override async write(
query: string,
params: unknown[] = [],
@@ -172,6 +175,9 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
};
}
@Span('db.batchWrite', (entries: unknown[]) => ({
'db.batch_size': entries.length,
}))
override async batchWrite(
entries: { statement: string; values: unknown[] }[],
): Promise<void> {
+12
View File
@@ -43,6 +43,7 @@ import { once } from 'node:events';
import { Agent as httpsAgent } from 'node:https';
import { HttpError } from '../../core/http';
import type { IConfig, IDynamoConfig } from '../../types';
import { Span } from '../../util/span.js';
import { PuterClient } from '../types';
const LOCAL_DYNAMO_MEMORY_PREFIX = ':memory:';
@@ -151,6 +152,7 @@ export class DDBClient extends PuterClient {
await this.#localInitPromise;
}
@Span('ddb.get', (table: string) => ({ 'db.table': table }))
async get<T extends Record<string, unknown>>(
table: string,
key: T,
@@ -167,6 +169,7 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
async put<T extends Record<string, unknown>>(table: string, item: T) {
const command = new PutCommand({
TableName: table,
@@ -178,6 +181,9 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.batchGet', (params: unknown[]) => ({
'db.batch_size': params.length,
}))
async batchGet(
params: { table: string; items: Record<string, unknown> }[],
consistentRead = false,
@@ -212,6 +218,9 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.batchPut', (params: unknown[]) => ({
'db.batch_size': params.length,
}))
async batchPut(params: { table: string; item: Record<string, unknown> }[]) {
const consumedCapacityByTable = new Map<string, number>();
if (params.length === 0) {
@@ -318,6 +327,7 @@ export class DDBClient extends PuterClient {
};
}
@Span('ddb.del', (table: string) => ({ 'db.table': table }))
async del<T extends Record<string, unknown>>(table: string, key: T) {
const command = new DeleteCommand({
TableName: table,
@@ -329,6 +339,7 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.query', (table: string) => ({ 'db.table': table }))
async query<T extends Record<string, unknown>>(
table: string,
keys: T,
@@ -383,6 +394,7 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.update', (table: string) => ({ 'db.table': table }))
async update<T extends Record<string, unknown>>(
table: string,
key: T,
+30 -12
View File
@@ -24,12 +24,20 @@ import type { IConfig } from '../../types';
import { PuterClient } from '../types';
import { EMAIL_TEMPLATES, type EmailTemplateName } from './templates';
// -- Types ------------------------------------------------------------
/** Attachment shape passed through to the underlying transport. */
export interface EmailAttachment {
filename: string;
content: Buffer | string;
contentType?: string;
encoding?: string;
}
// nodemailer doesn't ship TS types, so declare the subset we use.
interface NodemailerTransport {
sendMail: (options: SendMailOptions) => Promise<unknown>;
close?: () => void;
/** Subset of the transport's send result callers may care about. */
export interface SentMessageInfo {
messageId?: string;
accepted?: string[];
rejected?: string[];
[key: string]: unknown;
}
export interface SendMailOptions {
@@ -41,6 +49,7 @@ export interface SendMailOptions {
html?: string;
text?: string;
replyTo?: string;
attachments?: EmailAttachment[];
}
export type EmailValidator = (email: string) => Promise<boolean> | boolean;
@@ -100,7 +109,8 @@ const DOMAIN_ALIASES: Record<string, string> = {
* - Policy + extensible validation (via `validate`)
*/
export class EmailClient extends PuterClient {
private transport: NodemailerTransport | null = null;
private transport: ReturnType<typeof nodemailer.createTransport> | null =
null;
private compiledTemplates: Partial<
Record<EmailTemplateName, CompiledTemplate>
> = {};
@@ -123,8 +133,7 @@ export class EmailClient extends PuterClient {
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.transport = nodemailer.createTransport(emailConf as any);
this.transport = nodemailer.createTransport(emailConf);
console.log('[email] transport configured');
}
@@ -159,21 +168,30 @@ export class EmailClient extends PuterClient {
/**
* Raw send — bypasses the template system. Useful for one-off
* admin emails that don't warrant a named template.
*
* Returns the transport's send result, or `null` when no transport
* is configured (the send is a no-op in that case — callers that
* must not silently drop mail should check `isConfigured` first).
*/
async sendRaw(options: SendMailOptions): Promise<void> {
async sendRaw(options: SendMailOptions) {
if (!this.transport) {
console.warn(
'[email] attempted to send email without transport. If you need to send email, configure an SMTP transport in your config file (see docs for details). Email content:',
options,
);
return;
return null;
}
await this.transport.sendMail({
from: options.from ?? this.defaultFrom(),
return await this.transport.sendMail({
...options,
from: options.from ?? this.defaultFrom(),
});
}
/** Whether an SMTP transport is configured (sends are no-ops otherwise). */
get isConfigured(): boolean {
return this.transport !== null;
}
// -- Public API: clean / validate ---------------------------------
/**
+40 -18
View File
@@ -18,6 +18,7 @@
*/
import { extensionStore } from '../../extensions';
import { withSpan } from '../../util/span.js';
import { PuterClient } from '../types';
import {
EventListener,
@@ -99,26 +100,34 @@ export class EventClient extends PuterClient {
data: EventMap[T],
meta: EventMetadata,
) {
const parts = key.split('.');
for (let i = 0; i < parts.length; i++) {
const matchKey = (
i === parts.length - 1
? key
: `${parts.slice(0, i + 1).join('.')}.*`
) as ListenKey;
const extensionListeners = extensionStore.events[matchKey];
const listeners = (this.#eventListeners[matchKey] || []).concat(
extensionListeners || [],
);
if (!listeners) continue;
for (const listener of listeners) {
try {
await listener(key, data, meta);
} catch (e) {
console.error('Error in event listener for event', key, e);
// Spanned because callers block on listeners — this is where time
// spent in extension hooks (e.g. `ip.validate`) gets attributed.
return withSpan('event.emitAndWait', { 'event.key': key }, async () => {
const parts = key.split('.');
for (let i = 0; i < parts.length; i++) {
const matchKey = (
i === parts.length - 1
? key
: `${parts.slice(0, i + 1).join('.')}.*`
) as ListenKey;
const extensionListeners = extensionStore.events[matchKey];
const listeners = (this.#eventListeners[matchKey] || []).concat(
extensionListeners || [],
);
if (!listeners) continue;
for (const listener of listeners) {
try {
await listener(key, data, meta);
} catch (e) {
console.error(
'Error in event listener for event',
key,
e,
);
}
}
}
}
});
}
/**
@@ -146,6 +155,19 @@ export class EventClient extends PuterClient {
this.#eventListeners[key] ?? (this.#eventListeners[key] = []);
listeners.push(callback as EventListener);
}
off<P extends ListenKey>(
key: P,
callback: (
key: MatchingEvents<P>,
data: EventMap[MatchingEvents<P>],
meta: EventMetadata,
) => Promise<void> | void,
) {
const listeners = this.#eventListeners[key];
if (!listeners) return;
const idx = listeners.indexOf(callback as EventListener);
if (idx !== -1) listeners.splice(idx, 1);
}
async #emitEvent<T extends keyof EventMap>(
listener: EventListener,
key: T,
+4
View File
@@ -251,6 +251,7 @@ export type EventMap = {
fingerprint: string | null;
funding: string | null;
country: string | null;
customer_id: string | null;
[key: string]: unknown;
};
'user.card-verified': {
@@ -259,6 +260,7 @@ export type EventMap = {
fingerprint: string | null;
funding: string | null;
country: string | null;
customer_id: string | null;
};
'user.username-changed': {
user_id: number;
@@ -390,6 +392,8 @@ export type EventMap = {
// normalized path: `route.<method>.<path>.before|after|error|reject`. Same
// wildcard + veto semantics as the driver lifecycle above.
[K in `route.${string}`]: RouteLifecycleEvent;
} & {
[K in `pubsub.login.${string}`]: { authtoken: string };
};
/**
@@ -2008,6 +2008,62 @@ describe('AuthController.handleSendConfirmPhone validation', () => {
);
});
it('persists the send failure to KV under the error_id with a 7-day expiry', async () => {
const { user, actor } = await makeUserAndActor();
const kvSet = vi.spyOn(server.stores.kv, 'set');
try {
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => {
throw new Error('network down');
}),
}),
async () => {
let thrown: HttpError | undefined;
try {
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
);
} catch (e) {
thrown = e as HttpError;
}
const errorId = thrown!.fields!.error_id as string;
const { res: record } = await server.stores.kv.get({
key: `sms-send-error:${errorId}`,
});
expect(record).toMatchObject({
reason: 'prelude_request_failed',
status: 502,
user_id: user.id,
user_uid: user.uuid,
detail: 'network down',
t: expect.any(Number),
});
const errorSet = kvSet.mock.calls.find(([arg]) =>
(arg as { key?: string }).key?.startsWith(
'sms-send-error:',
),
);
const { expireAt } = errorSet![0] as {
expireAt: number;
};
const nowSec = Math.floor(Date.now() / 1000);
expect(expireAt).toBeGreaterThan(
nowSec + 7 * 24 * 60 * 60 - 60,
);
expect(expireAt).toBeLessThanOrEqual(
nowSec + 7 * 24 * 60 * 60,
);
},
);
} finally {
kvSet.mockRestore();
}
});
it('forwards ip, device fingerprint, and user-agent to Prelude as signals', async () => {
const { actor } = await makeUserAndActor();
const createVerification = vi.fn(async () => ({ status: 'success' }));
+101 -13
View File
@@ -20,7 +20,7 @@
import bcrypt from 'bcrypt';
import type { Request, RequestHandler, Response } from 'express';
import crypto from 'node:crypto';
import { v4 as uuidv4 } from 'uuid';
import { v4 as uuidv4, validate as validateUuid } from 'uuid';
import validator from 'validator';
import { Controller, Get, Post } from '../../core/http/decorators.js';
import type { HttpErrorOptions } from '../../core/http/HttpError.js';
@@ -72,6 +72,9 @@ const SEND_PHONE_RATE_WINDOW_MS = 60 * 60_000;
// Once the threshold is crossed the fallback stays open this long, so the
// user can finish the card flow without racing the attempt counter's expiry.
const CARD_FALLBACK_OPEN_TTL_SECONDS = 24 * 60 * 60;
// How long a failed-SMS-send record stays readable by its error_id — long
// enough to cover the typical support round-trip.
const SMS_SEND_ERROR_TTL_SECONDS = 7 * 24 * 60 * 60;
const RESERVED_USERNAMES = new Set([
'admin',
'administrator',
@@ -110,6 +113,67 @@ const RESERVED_USERNAMES = new Set([
*/
@Controller('')
export class AuthController extends PuterController {
@Post('/login/wait', {
subdomain: ['api'],
rateLimit: [
// A client will make a request to this every 10 seconds while waiting for the login to complete, so we allow a higher limit than the main /login endpoint.
{ scope: 'login-wait', limit: 100, window: 15 * 60_000, key: 'ip' },
],
})
async loginWait(req: Request, res: Response) {
const { session } = req.body;
// validate uuid to prevent ultra long key or listening on pubsub.login.*
if (!session || !validateUuid(session)) {
throw new HttpError(400, 'session is required.', {
legacyCode: 'bad_request',
});
}
const { resolve, promise } = Promise.withResolvers<void>();
let token: string | null = null;
const listener = (_key: string, value: { authtoken: string }) => {
token = value.authtoken;
resolve();
};
this.clients.event.on(`pubsub.login.${session}`, listener);
const timeout = new Promise<void>((resolve) =>
setTimeout(resolve, 10000),
);
await Promise.race([promise, timeout]);
this.clients.event.off(`pubsub.login.${session}`, listener);
if (!token) {
throw new HttpError(408, 'Request timeout.', {
legacyCode: 'request_timeout',
});
}
res.json({
auth_token: token,
});
}
@Post('/login/set', {
subdomain: ['api'],
})
async loginSet(req: Request, res: Response) {
const { session, auth_token } = req.body;
if (!session || !auth_token || !validateUuid(session)) {
throw new HttpError(400, 'session and auth_token are required.', {
legacyCode: 'bad_request',
});
}
this.clients.event.emit(
`pubsub.login.${session}`,
{
authtoken: auth_token,
},
{},
);
res.json({ success: true });
}
// -- Login -------------------------------------------------------
@Post('/login', {
@@ -1059,12 +1123,14 @@ export class AuthController extends PuterController {
* Build the error thrown when a verification SMS can't be sent (a delivery
* failure, or a refused/blocked send). Mints a short `error_id`, writes a
* single greppable line tying that id to the real reason (so support can
* look it up in CloudWatch with the id the user quotes), and returns the
* `HttpError` with the id attached as `error_id` for the GUI to surface.
* The phone number is deliberately omitted from the log line (PII); the
* user + country are enough to correlate.
* look it up in CloudWatch with the id the user quotes), stores the same
* record in KV under `sms-send-error:<error_id>` for a week (the admin
* abuse page looks it up there without needing log access), and returns
* the `HttpError` with the id attached as `error_id` for the GUI to
* surface. The phone number is deliberately omitted from the log line and
* the KV record (PII); the user + country are enough to correlate.
*/
private smsSendError(
private async smsSendError(
statusCode: number,
clientMessage: string,
reason: string,
@@ -1075,7 +1141,7 @@ export class AuthController extends PuterController {
detail?: unknown;
},
options: HttpErrorOptions = {},
): HttpError {
): Promise<HttpError> {
const errorId = uuidv4();
const detail =
ctx.detail instanceof Error ? ctx.detail.message : ctx.detail;
@@ -1086,6 +1152,26 @@ export class AuthController extends PuterController {
`country=${ctx.country ?? ''}` +
(detail ? ` detail=${JSON.stringify(String(detail))}` : ''),
);
// Best-effort: the record backs a support lookup, so a KV failure
// must never mask the error actually being reported.
try {
const now = Math.floor(Date.now() / 1000);
await this.stores.kv.set({
key: `sms-send-error:${errorId}`,
value: {
reason,
status: statusCode,
user_id: ctx.userId ?? null,
user_uid: ctx.userUid ?? null,
country: ctx.country ?? null,
detail: detail != null ? String(detail) : null,
t: now,
},
expireAt: now + SMS_SEND_ERROR_TTL_SECONDS,
});
} catch (e) {
console.warn('[send-confirm-phone] error-record store failed:', e);
}
return new HttpError(statusCode, clientMessage, {
...options,
fields: { ...options.fields, error_id: errorId },
@@ -1219,7 +1305,7 @@ export class AuthController extends PuterController {
legacyCode: 'account_suspended',
});
if (!this.clients.prelude?.isConfigured())
throw this.smsSendError(
throw await this.smsSendError(
503,
'Phone verification is unavailable.',
'prelude_not_configured',
@@ -1254,7 +1340,7 @@ export class AuthController extends PuterController {
// (see PreludeClient / countries.ts). Avoids paying exorbitant per-SMS
// rates in low-revenue, high-fraud geographies.
if (!this.clients.prelude.isCountrySupported(parsed.country))
throw this.smsSendError(
throw await this.smsSendError(
400,
'Phone verification is not available for this country.',
'country_not_supported',
@@ -1305,7 +1391,7 @@ export class AuthController extends PuterController {
// its meaning lives in the extension (which sets it) and the GUI (which
// displays it), so no abuse semantics leak into the OSS repo.
if (abuseCheck.allowed === false)
throw this.smsSendError(
throw await this.smsSendError(
429,
'Phone verification is unavailable for this number right now.',
`not_allowed:${abuseCheck.reason ?? 'unspecified'}`,
@@ -1339,7 +1425,7 @@ export class AuthController extends PuterController {
expireAt: Math.floor(Date.now() / 1000) + 60 * 60,
});
} catch (e) {
throw this.smsSendError(
throw await this.smsSendError(
503,
'Could not start phone verification.',
'pending_store_failed',
@@ -1378,7 +1464,7 @@ export class AuthController extends PuterController {
result.status === 'blocked' ||
result.status === 'shadow_blocked'
) {
throw this.smsSendError(
throw await this.smsSendError(
429,
'Phone verification is temporarily unavailable for this number.',
`prelude_${result.status}`,
@@ -1395,7 +1481,7 @@ export class AuthController extends PuterController {
}
} catch (e) {
if (e instanceof HttpError) throw e;
throw this.smsSendError(
throw await this.smsSendError(
502,
'Could not send verification code.',
'prelude_request_failed',
@@ -1725,6 +1811,7 @@ export class AuthController extends PuterController {
fingerprint: null as string | null,
funding: null as string | null,
country: null as string | null,
customer_id: null as string | null,
};
try {
await this.clients.event?.emitAndWait(
@@ -1781,6 +1868,7 @@ export class AuthController extends PuterController {
fingerprint: confirmEvent.fingerprint,
funding: confirmEvent.funding,
country: confirmEvent.country,
customer_id: confirmEvent.customer_id,
} as never,
{},
);
@@ -19,6 +19,12 @@
import { Readable } from 'node:stream';
import type { Request, RequestHandler, Response } from 'express';
import { trace } from '@opentelemetry/api';
import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-base';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { actorUid, type Actor } from '../../core/actor.js';
@@ -615,4 +621,65 @@ describe('DriverController.registerRoutes', () => {
});
});
describe('DriverController.#handleCall tracing', () => {
let routes: Captured;
const exporter = new InMemorySpanExporter();
const provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
beforeAll(() => {
routes = captureRoutes(controller);
trace.setGlobalTracerProvider(provider);
});
afterAll(async () => {
await provider.shutdown();
trace.disable();
});
it('emits driver + downstream spans around a successful call', async () => {
const actor = await makeUserActor();
await server.stores.permission.setFlatUserPerm(
actor.user!.id!,
'service:puter-kvstore:ii:puter-kvstore',
{
permission: 'service:puter-kvstore:ii:puter-kvstore',
deleted: false,
issuer_user_id: actor.user!.id!,
} as never,
);
exporter.reset();
const res = makeRes();
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key: `k-${uuidv4()}`, value: 'v' },
},
actor,
);
await runWithContext({ actor }, () =>
routes['POST /call'](req, res as unknown as Response, () => {}),
);
expect((res.body as { success: boolean }).success).toBe(true);
const names = exporter.getFinishedSpans().map((s) => s.name);
const driverSpan = exporter
.getFinishedSpans()
.find((s) => s.name === 'driver.puter-kvstore.set');
expect(driverSpan).toBeDefined();
expect(driverSpan!.attributes).toMatchObject({
driver: 'puter-kvstore',
'driver.method': 'set',
'driver.name': 'puter-kvstore',
});
// The call path exercises the permission gate and the KV store's
// dynamo writes — both should have produced spans of their own.
expect(names).toContain('permission.scan');
expect(names.some((n) => n.startsWith('ddb.'))).toBe(true);
});
});
void vi; // unused but reserved for future expansion
@@ -37,6 +37,7 @@ import {
import type { PermissionService } from '../../services/permission/PermissionService.js';
import { PermissionUtil } from '../../services/permission/permissionUtil.js';
import type { WithLifecycle } from '../../types';
import { withSpan } from '../../util/span.js';
import { PuterController } from '../types.js';
type DriverInstance = WithLifecycle & Record<string, unknown>;
@@ -382,11 +383,22 @@ export class DriverController extends PuterController {
}
// Drivers read actor/context via the Context API — no drilled args.
// The span ends when the method returns; for streamed results that
// is stream start, not stream drain (same window the lifecycle
// events below report as durationMs).
const startedAt = Date.now();
let result;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = await (fn as (...x: unknown[]) => any).call(driver, args);
result = await withSpan(
`driver.${ifaceName}.${method}`,
{
driver: ifaceName,
'driver.method': method,
'driver.name': resolved,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => (fn as (...x: unknown[]) => any).call(driver, args),
);
} catch (e) {
this.clients.event?.emit(
`driver.${ifaceName}.${method}.error`,
@@ -52,6 +52,12 @@ const TEST_ORIGIN = 'http://test.local';
let server: PuterServer;
let router: PuterRouter;
// Stand-in for the abuse extension's signup veto. EventClient has no off(),
// so one shared listener is installed in beforeAll and tests swap the
// override in and out (same pattern as AuthController.test.ts).
type SignupValidateOverride = (data: Record<string, unknown>) => void;
let signupValidateOverride: SignupValidateOverride | null = null;
beforeAll(async () => {
server = await setupTestServer({
origin: TEST_ORIGIN,
@@ -75,6 +81,14 @@ beforeAll(async () => {
} as never);
router = new PuterRouter();
server.controllers.oidc.registerRoutes(router);
server.clients.event.on(
'puter.signup.validate',
(_k: unknown, data: unknown) => {
if (signupValidateOverride) {
signupValidateOverride(data as Record<string, unknown>);
}
},
);
});
afterAll(async () => {
@@ -83,6 +97,7 @@ afterAll(async () => {
afterEach(() => {
vi.restoreAllMocks();
signupValidateOverride = null;
});
interface CapturedResponse {
@@ -92,6 +107,7 @@ interface CapturedResponse {
redirectUrl?: string;
headers: Record<string, string>;
cookies: Array<{ name: string; value: string; opts?: unknown }>;
clearedCookies: Array<{ name: string; opts?: unknown }>;
contentType?: string;
}
@@ -100,6 +116,7 @@ const makeReq = (init: {
query?: Record<string, unknown>;
params?: Record<string, unknown>;
headers?: Record<string, string>;
cookies?: Record<string, string>;
method?: string;
}): Request => {
return {
@@ -107,6 +124,7 @@ const makeReq = (init: {
query: init.query ?? {},
params: init.params ?? {},
headers: init.headers ?? {},
cookies: init.cookies ?? {},
method: init.method ?? 'GET',
} as unknown as Request;
};
@@ -117,6 +135,7 @@ const makeRes = () => {
body: undefined,
headers: {},
cookies: [],
clearedCookies: [],
};
const res = {
json: vi.fn((value: unknown) => {
@@ -155,6 +174,10 @@ const makeRes = () => {
captured.cookies.push({ name, value, opts });
return res;
}),
clearCookie: vi.fn((name: string, opts?: unknown) => {
captured.clearedCookies.push({ name, opts });
return res;
}),
type: vi.fn(() => res),
};
return { res: res as unknown as Response, captured };
@@ -593,6 +616,7 @@ describe('OIDCController login callback', () => {
);
expect(captured.redirectStatus).toBe(302);
expect(captured.redirectUrl).toContain('auth_error=1');
expect(captured.redirectUrl).toContain('message=account_suspended');
// No session cookie issued for suspended accounts.
expect(captured.cookies).toHaveLength(0);
});
@@ -674,9 +698,7 @@ describe('OIDCController login callback', () => {
expect(captured.redirectUrl).toContain('embedded_in_popup=true');
expect(captured.redirectUrl).toContain('msg_id=msg-99');
expect(captured.redirectUrl).toContain('auth_error=1');
// 'This account is suspended.' is not in ALLOWED_ERRORS, so the
// builder falls back to 'unauthorized'.
expect(captured.redirectUrl).toContain('message=unauthorized');
expect(captured.redirectUrl).toContain('message=account_suspended');
expect(captured.redirectUrl).toContain(
`opener_origin=${encodeURIComponent('http://opener.test')}`,
);
@@ -767,6 +789,291 @@ describe('OIDCController login callback', () => {
});
});
// -- Browser binding / login-CSRF --------------------------------------
describe('OIDCController signup veto (abuse harness)', () => {
const vetoWithTrail = (email: string, trailId: string) => {
signupValidateOverride = (data) => {
if (data.email !== email) return;
data.allow = false;
data.trail_id = trailId;
};
};
const stubIdp = (sub: string, email: string) => {
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
access_token: 'access',
id_token: 'id',
} as never);
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
sub,
email,
email_verified: true,
} as never);
};
it('createUserFromOIDC returns signup_blocked and the trail id as requestCode', async () => {
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
vetoWithTrail(email, 'trail-svc-1');
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
email,
email_verified: true,
}),
);
expect(created.success).toBe(false);
expect(created.code).toBe('signup_blocked');
expect(created.requestCode).toBe('trail-svc-1');
});
it('login callback redirects with signup_blocked + request_code when first sign-in is vetoed', async () => {
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
vetoWithTrail(email, 'trail-login-1');
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
});
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/login',
makeReq({ query: { code: 'c', state } }),
res,
);
expect(captured.redirectStatus).toBe(302);
expect(captured.redirectUrl).toContain('auth_error=1');
expect(captured.redirectUrl).toContain('action=login');
expect(captured.redirectUrl).toContain('message=signup_blocked');
expect(captured.redirectUrl).toContain('request_code=trail-login-1');
expect(captured.cookies).toHaveLength(0);
});
it('signup callback redirects with signup_blocked + request_code when vetoed', async () => {
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
vetoWithTrail(email, 'trail-signup-1');
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
});
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/signup',
makeReq({ query: { code: 'c', state } }),
res,
);
expect(captured.redirectStatus).toBe(302);
expect(captured.redirectUrl).toContain('auth_error=1');
expect(captured.redirectUrl).toContain('action=signup');
expect(captured.redirectUrl).toContain('message=signup_blocked');
expect(captured.redirectUrl).toContain('request_code=trail-signup-1');
expect(captured.cookies).toHaveLength(0);
});
it('a veto with no trail id still redirects with signup_blocked and no request_code', async () => {
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
signupValidateOverride = (data) => {
if (data.email !== email) return;
data.allow = false;
};
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
});
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/signup',
makeReq({ query: { code: 'c', state } }),
res,
);
expect(captured.redirectUrl).toContain('message=signup_blocked');
expect(captured.redirectUrl).not.toContain('request_code');
});
});
describe('OIDCController browser binding', () => {
const NONCE_COOKIE = 'puter_oidc_nonce';
const stubIdP = (sub: string, email: string) => {
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
access_token: 'access',
id_token: 'id',
} as never);
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
sub,
email,
email_verified: true,
} as never);
};
it('/start sets an HttpOnly nonce cookie matching the nonce embedded in state', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/:provider/start',
makeReq({ params: { provider: 'custom' } }),
res,
);
const nonceCookie = captured.cookies.find(
(c) => c.name === NONCE_COOKIE,
);
expect(nonceCookie).toBeTruthy();
expect(nonceCookie?.value).toBeTruthy();
expect((nonceCookie?.opts as { httpOnly?: boolean })?.httpOnly).toBe(
true,
);
// The cookie value must equal the nonce baked into the signed state.
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
'state',
);
const decoded = oidc().verifyState(state!);
expect(decoded?.nonce).toBe(nonceCookie?.value);
});
it('completes login when the nonce cookie matches the state nonce', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `bind-${Math.random().toString(36).slice(2, 8)}@test.local`;
const nonce = 'browser-nonce-match';
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
nonce,
});
stubIdP(sub, email);
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/login',
makeReq({
query: { code: 'c', state },
cookies: { [NONCE_COOKIE]: nonce },
}),
res,
);
// Session cookie issued; single-use nonce cookie cleared.
expect(captured.cookies).toHaveLength(1);
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
expect(
captured.clearedCookies.some((c) => c.name === NONCE_COOKIE),
).toBe(true);
});
it('rejects login (no session cookie) when the nonce cookie is absent — the login-CSRF case', async () => {
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
nonce: 'attacker-flow-nonce',
});
// If enforcement were missing, this would resolve a user and set a
// session cookie for the victim's browser. It must not get that far.
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/login',
// Victim's browser has no nonce cookie for the attacker's flow.
makeReq({ query: { code: 'c', state }, cookies: {} }),
res,
);
expect(captured.redirectStatus).toBe(302);
expect(captured.redirectUrl).toContain('auth_error=1');
expect(captured.cookies).toHaveLength(0);
// We bail before ever exchanging the code.
expect(exchangeSpy).not.toHaveBeenCalled();
});
it('rejects login when the nonce cookie does not match the state nonce', async () => {
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
nonce: 'expected-nonce',
});
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/login',
makeReq({
query: { code: 'c', state },
cookies: { [NONCE_COOKIE]: 'a-different-nonce' },
}),
res,
);
expect(captured.redirectUrl).toContain('auth_error=1');
expect(captured.cookies).toHaveLength(0);
expect(exchangeSpy).not.toHaveBeenCalled();
});
it('rejects the revalidate callback (400) when the nonce cookie is missing', async () => {
const state = oidc().signState({
provider: 'custom',
flow: 'revalidate',
user_uuid: uuidv4(),
nonce: 'reval-nonce',
});
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/revalidate',
makeReq({ query: { code: 'c', state }, cookies: {} }),
res,
);
expect(captured.statusCode).toBe(400);
expect(exchangeSpy).not.toHaveBeenCalled();
});
it('lets legacy nonce-less state through (deploy grace) without touching the nonce cookie', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `legacy-${Math.random().toString(36).slice(2, 8)}@test.local`;
// No `nonce` field — mimics a state signed before this shipped.
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
});
stubIdP(sub, email);
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/login',
makeReq({ query: { code: 'c', state }, cookies: {} }),
res,
);
// Proceeds as before; no nonce cookie is cleared for legacy states.
expect(captured.cookies).toHaveLength(1);
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
expect(
captured.clearedCookies.some((c) => c.name === NONCE_COOKIE),
).toBe(false);
});
});
// ── /auth/oidc/callback/signup ──────────────────────────────────────
describe('OIDCController signup callback', () => {
+100 -21
View File
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import type { Request, Response } from 'express';
import { HttpError } from '../../core/http/HttpError.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
@@ -26,45 +27,63 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js';
const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
const REVALIDATION_EXPIRY_SEC = 300;
// Companion cookie that binds an OIDC flow to the browser that started it.
// Expiry mirrors STATE_EXPIRY_SEC in OIDCService — the state and its
// browser-binding cookie must expire together.
const OIDC_NONCE_COOKIE_NAME = 'puter_oidc_nonce';
const OIDC_NONCE_EXPIRY_SEC = 600;
const OIDC_ERROR_REDIRECT_MAP: Record<string, Record<string, string>> = {
login: { account_not_found: 'signup', other: 'login' },
signup: { account_already_exists: 'login', other: 'signup' },
};
const ALLOWED_ERRORS = ['account_suspended', 'unauthorized'] as const;
// The `message` query param is clamped to these codes — the GUI maps them to
// display text. Free-text errors (which may describe internals or, for vetoed
// signups, the block reason) never reach the redirect URL.
const ALLOWED_ERRORS = [
'account_suspended',
'unauthorized',
'signup_blocked',
] as const;
function buildErrorRedirectUrl(
origin: string,
sourceFlow: string,
errorCondition: string,
message: (typeof ALLOWED_ERRORS)[number],
message: string,
stateDecoded?: Record<string, unknown>,
requestCode?: string,
): string {
const targetFlow =
OIDC_ERROR_REDIRECT_MAP[sourceFlow]?.[errorCondition] ?? sourceFlow;
const base = origin.replace(/\/$/, '') || '/';
const clamped = (ALLOWED_ERRORS as readonly string[]).includes(message)
? message
: 'unauthorized';
let params: URLSearchParams;
if (stateDecoded?.embedded_in_popup && stateDecoded?.msg_id != null) {
const params = new URLSearchParams({
params = new URLSearchParams({
embedded_in_popup: 'true',
msg_id: String(stateDecoded.msg_id),
auth_error: '1',
message: ALLOWED_ERRORS.includes(message)
? message
: 'unauthorized',
message: clamped,
action: targetFlow,
});
if (stateDecoded?.opener_origin) {
params.set('opener_origin', String(stateDecoded.opener_origin));
}
return `${base}/?${params.toString()}`;
} else {
params = new URLSearchParams({
action: targetFlow,
auth_error: '1',
message: clamped,
});
}
if (requestCode) {
params.set('request_code', requestCode);
}
const params = new URLSearchParams({
action: targetFlow,
auth_error: '1',
message: ALLOWED_ERRORS.includes(message) ? message : 'unauthorized',
});
return `${base}/?${params.toString()}`;
}
@@ -73,6 +92,14 @@ function appendQueryParam(url: string, key: string, value: string): string {
return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}
/** Length-safe constant-time string compare (never throws on mismatch). */
function constantTimeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
if (ba.length !== bb.length) return false;
return crypto.timingSafeEqual(ba, bb);
}
/**
* True iff `target` parses as a URL whose origin equals `origin`. Used to
* clamp OIDC redirect targets — `startsWith` would accept
@@ -211,6 +238,17 @@ export class OIDCController extends PuterController {
statePayload.flow = 'revalidate';
}
// Bind this flow to the initiating browser: a single-use
// nonce lives both in the signed `state` and in an HttpOnly
// companion cookie. The callback requires them to match, so a
// `state` captured from an attacker's own flow can't be
// replayed in a victim's browser (login-CSRF / session
// fixation).
const browserNonce = crypto
.randomBytes(32)
.toString('base64url');
statePayload.nonce = browserNonce;
const state = this.services.oidc.signState(statePayload);
const url = await this.services.oidc.getAuthorizationUrl(
provider,
@@ -224,6 +262,15 @@ export class OIDCController extends PuterController {
{ legacyCode: 'internal_error' },
);
res.cookie(OIDC_NONCE_COOKIE_NAME, browserNonce, {
// Same flags as the session cookie: SameSite=None;Secure
// on HTTPS so the cookie survives Apple's cross-site
// form_post callback; Lax on plain-HTTP self-host.
...sessionCookieFlags(this.config),
httpOnly: true,
maxAge: OIDC_NONCE_EXPIRY_SEC * 1000,
path: '/',
});
res.redirect(302, url);
},
);
@@ -237,7 +284,7 @@ export class OIDCController extends PuterController {
const loginCb = async (req: Request, res: Response) => {
const origin = this.config.origin ?? '';
const result = await this.#processCallback(req, 'login');
const result = await this.#processCallback(req, res, 'login');
if ('error' in result) {
console.warn(`OIDC login callback error: ${result.error}`);
return res.redirect(
@@ -268,8 +315,9 @@ export class OIDCController extends PuterController {
origin,
'login',
'other',
resolved.error,
resolved.code ?? 'unauthorized',
stateDecoded,
resolved.requestCode,
),
);
}
@@ -285,7 +333,7 @@ export class OIDCController extends PuterController {
origin,
'login',
'other',
'This account is suspended.',
'account_suspended',
stateDecoded,
),
);
@@ -300,7 +348,7 @@ export class OIDCController extends PuterController {
const signupCb = async (req: Request, res: Response) => {
const origin = this.config.origin ?? '';
const result = await this.#processCallback(req, 'signup');
const result = await this.#processCallback(req, res, 'signup');
if ('error' in result) {
return res.redirect(
302,
@@ -321,14 +369,18 @@ export class OIDCController extends PuterController {
(stateDecoded.referrer as string) ?? null,
);
if ('error' in resolved) {
console.warn(
`OIDC signup user resolution error: ${resolved.error}`,
);
return res.redirect(
302,
buildErrorRedirectUrl(
origin,
'signup',
'other',
'unauthorized',
resolved.code ?? 'unauthorized',
stateDecoded,
resolved.requestCode,
),
);
}
@@ -365,7 +417,7 @@ export class OIDCController extends PuterController {
req: Request,
res: Response,
): Promise<void> => {
const result = await this.#processCallback(req, 'revalidate');
const result = await this.#processCallback(req, res, 'revalidate');
if ('error' in result) {
res.status(400).send(result.error);
return;
@@ -464,7 +516,7 @@ if (window.opener) {
userinfo: { sub: string; email?: unknown; [k: string]: unknown },
referrer?: string | null,
): Promise<
| { error: string }
| { error: string; code?: string; requestCode?: string }
| {
user: import('../../stores/user/UserStore.js').UserRow;
origin: 'linked-sub' | 'linked-email' | 'created';
@@ -510,13 +562,18 @@ if (window.opener) {
referrer,
);
if (!outcome.success || !outcome.user) {
return { error: outcome.error ?? 'Account creation failed.' };
return {
error: outcome.error ?? 'Account creation failed.',
code: outcome.code,
requestCode: outcome.requestCode,
};
}
return { user: outcome.user, origin: 'created' };
}
async #processCallback(
req: Request,
res: Response,
flow: string,
): Promise<
| { error: string }
@@ -540,6 +597,28 @@ if (window.opener) {
if (!stateDecoded || !stateDecoded.provider)
return { error: 'Invalid or expired state.' };
// Enforce the browser binding set at /start. Every state minted by
// the current /start carries a nonce, so this covers all live flows.
// States signed before this shipped have no nonce and pass through
// until they expire (STATE_EXPIRY, 10 min) so in-flight logins don't
// break on deploy — a caller can't forge a nonce-less state because
// /start always adds one and the state is server-signed.
const expectedNonce =
typeof stateDecoded.nonce === 'string' ? stateDecoded.nonce : '';
if (expectedNonce) {
const cookieNonce = req.cookies?.[OIDC_NONCE_COOKIE_NAME];
// Single-use: drop the cookie regardless of the outcome.
res.clearCookie(OIDC_NONCE_COOKIE_NAME, { path: '/' });
if (
typeof cookieNonce !== 'string' ||
!constantTimeEqual(cookieNonce, expectedNonce)
) {
return {
error: 'This sign-in could not be verified for your browser. Please start again.',
};
}
}
const provider = String(stateDecoded.provider);
const callbackUrl = this.services.oidc.getCallbackUrl(flow);
if (!callbackUrl) return { error: 'Invalid flow.' };
@@ -44,7 +44,9 @@ import {
} from 'vitest';
import type { Actor } from '../../core/actor.js';
import type { RouteOptions } from '../../core/http/index.js';
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { PuterAIController } from './PuterAIController.js';
@@ -192,7 +194,10 @@ describe('PuterAIController.registerRoutes', () => {
// call puter-chat-completion directly), but admit the user's own
// full-access personal access token: `requireUserActor` keeps apps out
// and `allowFullAccessToken` opens the gate to a full-access PAT. Each
// proxy route carries both flags.
// proxy route carries both flags, plus the shared per-tier AI
// rate-limit / concurrency policy — these routes bypass the
// `/drivers/call` dispatch (where the driver-declared limits are
// enforced), so without the route gates they'd be unthrottled.
const userOnlyPaths = [
'/puterai/openai/v1/chat/completions',
'/puterai/openai/v1/completions',
@@ -205,6 +210,16 @@ describe('PuterAIController.registerRoutes', () => {
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: {
...AI_RATE_LIMIT.default,
scope: 'driver:puter-chat-completion:complete',
key: expect.any(Function),
},
concurrent: {
...AI_CONCURRENT.default,
scope: 'driver:puter-chat-completion:complete',
key: expect.any(Function),
},
});
}
const modelsRoute = calls.find(
@@ -215,6 +230,46 @@ describe('PuterAIController.registerRoutes', () => {
requireAuth: false,
});
});
it('keys the proxy-route AI limits by user uuid so they share the /drivers/call buckets', () => {
const calls: Array<{ path: string; opts: RouteOptions }> = [];
const router = {
post: vi.fn((path: string, opts: RouteOptions) => {
calls.push({ path, opts });
return router;
}),
get: vi.fn(() => router),
};
controller.registerRoutes(router as never);
const route = calls.find(
(c) => c.path === '/puterai/openai/v1/chat/completions',
);
const rateLimit = route?.opts.rateLimit as {
key: (req: Request) => string;
scope: string;
};
const concurrent = route?.opts.concurrent as {
key: (req: Request) => string;
scope: string;
};
// The dispatch buckets requests as `driver:<iface>:<method>:<uid>`
// where uid is the actor's user uuid; scope + key must compose to the
// identical string or wire traffic mints a second per-user budget.
const req = makeReq({ actor: makeUserActor() });
expect(rateLimit.key(req)).toBe('u-7');
expect(concurrent.key(req)).toBe('u-7');
expect(rateLimit.scope).toBe('driver:puter-chat-completion:complete');
expect(concurrent.scope).toBe('driver:puter-chat-completion:complete');
// No-actor fallback still yields a usable (fingerprint) key rather
// than throwing or bucketing everyone together under undefined.
const anonKey = rateLimit.key(makeReq({}));
expect(typeof anonKey).toBe('string');
expect(anonKey.length).toBeGreaterThan(0);
});
});
// ── /openai/v1/chat/completions ─────────────────────────────────────
@@ -22,6 +22,7 @@ import crypto from 'node:crypto';
import { Readable } from 'node:stream';
import { HttpError } from '../../core/http/HttpError.js';
import { RouteOptions } from '../../core/http/index.js';
import { computeNetworkFingerprint } from '../../core/http/middleware/rateLimit.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
import type {
@@ -29,6 +30,7 @@ import type {
ICompleteArguments,
} from '../../drivers/ai-chat/types.js';
import { isDriverStreamResult } from '../../drivers/meta.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
import { PuterController } from '../types.js';
const GEMINI_DOWNLOAD_BASE =
@@ -49,6 +51,18 @@ const GEMINI_DOWNLOAD_BASE =
*/
export class PuterAIController extends PuterController {
registerRoutes(router: PuterRouter): void {
/**
* The wire routes call the chat driver directly instead of going
* through the `/drivers/call` dispatch, so the shared per-tier AI
* rate-limit / concurrency policy must be declared as route gates
* here. `scope` + `key` reproduce the dispatch's bucket key
* (`driver:<iface>:<method>:<uid>`) exactly, so wire traffic and
* `/drivers/call` traffic draw from one per-user budget rather than
* each surface minting its own.
*/
const aiPolicyScope = 'driver:puter-chat-completion:complete';
const aiPolicyKey = (req: Request): string =>
req.actor?.user?.uuid || computeNetworkFingerprint(req);
const apiAuthOpts = {
subdomain: 'api',
requireUserActor: true,
@@ -58,6 +72,16 @@ export class PuterAIController extends PuterController {
// personal access tokens (CLI/MCP/scripts). Apps and scoped tokens
// stay blocked.
allowFullAccessToken: true,
rateLimit: {
...AI_RATE_LIMIT.default!,
scope: aiPolicyScope,
key: aiPolicyKey,
},
concurrent: {
...AI_CONCURRENT.default!,
scope: aiPolicyScope,
key: aiPolicyKey,
},
} as RouteOptions;
const publicOpts = { subdomain: 'api', requireAuth: false } as const;
@@ -37,17 +37,33 @@ export class SystemController extends PuterController {
) {
// -- Healthcheck ---------------------------------------------
// Delegates to ServerHealthService for the real check-based
// status. Returns `{ ok: true }` when all registered checks pass,
// or `{ ok: false, failed: [...] }` + 503 when any fail or the
// status. Returns `{ ok: true }` + 200 when all registered checks
// pass, or `{ ok: false, failed: [...] }` + 503 when any fail or the
// server is draining.
router.get('/healthcheck', { subdomain: '*' }, async (_req, res) => {
//
// `?ignore=a,b` disregards the named checks for this request only.
// `?marked-degraded=a,b` demotes the named checks to a non-fatal
// `degraded` list: `ok` stays true but the response is 207 so the
// caller can tell the node is running in a degraded state.
const parseNames = (value) =>
typeof value === 'string'
? value
.split(',')
.map((name) => name.trim())
.filter(Boolean)
: [];
router.get('/healthcheck', { subdomain: '*' }, async (req, res) => {
const health = this.services.health;
if (!health || typeof health.getStatus !== 'function') {
// Fallback for boot ordering / missing service.
return res.send('ok');
}
const status = await health.getStatus();
const status = await health.getStatus({
ignore: parseNames(req.query.ignore),
degrade: parseNames(req.query['marked-degraded']),
});
if (!status.ok) return res.status(503).json(status);
if (status.degraded?.length) return res.status(207).json(status);
return res.json(status);
});
@@ -18,9 +18,10 @@
*/
import type { Request, RequestHandler, Response } from 'express';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../core/actor.js';
import { kv } from '../../util/kvSingleton.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
@@ -79,10 +80,11 @@ interface CapturedResponse {
const makeReq = (init: {
body?: unknown;
actor?: Actor;
query?: Record<string, unknown>;
}): Request => {
return {
body: init.body ?? {},
query: {},
query: init.query ?? {},
headers: {},
actor: init.actor,
} as unknown as Request;
@@ -139,6 +141,191 @@ describe('SystemController GET /healthcheck', () => {
expect(captured.body).toMatchObject({ ok: true });
expect(captured.statusCode).toBe(200);
});
it('parses ?ignore and ?marked-degraded into trimmed name lists', async () => {
const spy = vi
.spyOn(server.services.health, 'getStatus')
.mockResolvedValue({ ok: true });
try {
const { res } = makeRes();
await callRoute(
'get',
'/healthcheck',
makeReq({
query: {
ignore: 'database-liveness, thumbnailer',
'marked-degraded': ' socket-initialized ',
},
}),
res,
);
expect(spy).toHaveBeenCalledWith({
ignore: ['database-liveness', 'thumbnailer'],
degrade: ['socket-initialized'],
});
} finally {
spy.mockRestore();
}
});
it('returns ok:true + 200 when the only failures are ignored', async () => {
const spy = vi
.spyOn(server.services.health, 'getStatus')
.mockImplementation(async ({ ignore = [] } = {}) => {
const failed = ['database-liveness'].filter(
(name) => !ignore.includes(name),
);
return failed.length === 0
? { ok: true }
: { ok: false, failed };
});
try {
const { res, captured } = makeRes();
await callRoute(
'get',
'/healthcheck',
makeReq({ query: { ignore: 'database-liveness' } }),
res,
);
expect(captured.body).toEqual({ ok: true });
expect(captured.statusCode).toBe(200);
} finally {
spy.mockRestore();
}
});
it('returns ok:true + 207 when the only failures are marked degraded', async () => {
const spy = vi
.spyOn(server.services.health, 'getStatus')
.mockResolvedValue({ ok: true, degraded: ['database-liveness'] });
try {
const { res, captured } = makeRes();
await callRoute(
'get',
'/healthcheck',
makeReq({ query: { 'marked-degraded': 'database-liveness' } }),
res,
);
expect(captured.body).toEqual({
ok: true,
degraded: ['database-liveness'],
});
expect(captured.statusCode).toBe(207);
} finally {
spy.mockRestore();
}
});
it('still 503s when a non-ignored failure remains', async () => {
const spy = vi
.spyOn(server.services.health, 'getStatus')
.mockImplementation(async ({ ignore = [] } = {}) => {
const failed = ['database-liveness', 'socket-initialized'].filter(
(name) => !ignore.includes(name),
);
return failed.length === 0
? { ok: true }
: { ok: false, failed };
});
try {
const { res, captured } = makeRes();
await callRoute(
'get',
'/healthcheck',
makeReq({ query: { ignore: 'database-liveness' } }),
res,
);
expect(captured.statusCode).toBe(503);
expect(captured.body).toEqual({
ok: false,
failed: ['socket-initialized'],
});
} finally {
spy.mockRestore();
}
});
});
// ── ServerHealthService.getStatus ignore / degrade filtering ────────
//
// Exercises the real service by seeding the in-process status cache
// (the kv.js singleton) it reads from, so the actual per-request
// classification runs — not a stubbed getStatus.
describe('ServerHealthService.getStatus ignore/degrade filtering', () => {
const STATUS_CACHE_KEY = 'server-health:status';
const seedStatus = (status: unknown) => {
kv.set(STATUS_CACHE_KEY, status, { EX: 5 });
};
afterEach(() => {
kv.del(STATUS_CACHE_KEY);
});
it('collapses to ok:true when every failure is ignored', async () => {
seedStatus({ ok: false, failed: ['database-liveness', 'thumbnailer'] });
const status = await server.services.health.getStatus({
ignore: ['database-liveness', 'thumbnailer'],
});
expect(status).toEqual({ ok: true });
});
it('keeps the non-ignored failures', async () => {
seedStatus({ ok: false, failed: ['database-liveness', 'thumbnailer'] });
const status = await server.services.health.getStatus({
ignore: ['database-liveness'],
});
expect(status).toEqual({ ok: false, failed: ['thumbnailer'] });
});
it('is a no-op for a healthy status', async () => {
seedStatus({ ok: true });
const status = await server.services.health.getStatus({
ignore: ['database-liveness'],
});
expect(status).toEqual({ ok: true });
});
it('ignores unknown names without affecting real failures', async () => {
seedStatus({ ok: false, failed: ['database-liveness'] });
const status = await server.services.health.getStatus({
ignore: ['not-a-check'],
});
expect(status).toEqual({ ok: false, failed: ['database-liveness'] });
});
it('demotes marked failures to degraded and stays ok:true', async () => {
seedStatus({ ok: false, failed: ['database-liveness'] });
const status = await server.services.health.getStatus({
degrade: ['database-liveness'],
});
expect(status).toEqual({ ok: true, degraded: ['database-liveness'] });
});
it('reports degraded alongside remaining hard failures (ok:false)', async () => {
seedStatus({
ok: false,
failed: ['database-liveness', 'socket-initialized'],
});
const status = await server.services.health.getStatus({
degrade: ['database-liveness'],
});
expect(status).toEqual({
ok: false,
failed: ['socket-initialized'],
degraded: ['database-liveness'],
});
});
it('lets ignore take precedence over degrade for the same name', async () => {
seedStatus({ ok: false, failed: ['database-liveness'] });
const status = await server.services.health.getStatus({
ignore: ['database-liveness'],
degrade: ['database-liveness'],
});
expect(status).toEqual({ ok: true });
});
});
// ── /version ────────────────────────────────────────────────────────
@@ -335,6 +335,63 @@ describe('WebDAVController', () => {
});
});
describe('suspension gate', () => {
// WebDAV must also enforce the suspension gate every other authenticated
// route gets from requireAuthGate. Same single-use() dispatch means that
// middleware is never wired in, so #dispatch calls assertNotSuspended
// itself. Without it, a suspended account could read/write/delete its
// whole filesystem over the `dav` subdomain.
it('rejects a suspended session actor with 403', async () => {
const { res, captured } = makeRes();
await dispatchMiddleware(
makeReq({
method: 'PROPFIND',
actor: {
user: {
id: 1,
uuid: 'suspended-uuid',
username: 'suspended',
suspended: true,
},
},
}),
res,
noop,
);
expect(captured.statusCode).toBe(403);
});
it('rejects a suspended user on the Basic-auth path with 403', async () => {
const username = `webdav-suspended-${Math.random()
.toString(36)
.slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: await bcryptHash('correct-horse', 4),
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await server.stores.user.update(created.id, {
suspended: 1,
});
const { res, captured } = makeRes();
await dispatchMiddleware(
makeReq({
method: 'PROPFIND',
headers: {
authorization: basicAuth(username, 'correct-horse'),
},
}),
res,
noop,
);
expect(captured.statusCode).toBe(403);
});
});
describe('unsupported methods', () => {
it('returns 405 for unknown HTTP methods', async () => {
const { res, captured } = makeRes();
@@ -23,7 +23,10 @@ import { posix as pathPosix } from 'node:path';
import { EventMap } from '../../clients/event/types.js';
import type { Actor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import { assertVerifiedAccount } from '../../core/http/middleware/gates.js';
import {
assertNotSuspended,
assertVerifiedAccount,
} from '../../core/http/middleware/gates.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import { verify as verifyOtp } from '../../services/auth/OTPUtil.js';
import { expandTildePath } from '../../services/fs/resolveNode.js';
@@ -90,14 +93,15 @@ export class WebDAVController extends PuterController {
const actor = await this.#resolveActor(req, res);
if (!actor) return; // 401 already sent
// Apply the same pending-verification gate every other authenticated
// route gets from `requireVerifiedAccount`. WebDAV dispatches every
// method off a single `router.use` with no route options, so that
// middleware is never inserted into its chain — without this call an
// account still pending email / phone / card verification could read,
// write, and delete its entire filesystem over the `dav` subdomain,
// bypassing the gate. Throws a 403 HttpError, surfaced by the catch in
// registerRoutes.
// Apply the same suspension + pending-verification gates every other
// authenticated route gets from `requireAuthGate` / `requireVerifiedAccount`.
// WebDAV dispatches every method off a single `router.use` with no route
// options, so that middleware is never inserted into its chain — without
// these calls a suspended account (or one still pending email / phone /
// card verification) could read, write, and delete its entire filesystem
// over the `dav` subdomain, bypassing the gates. Both throw a 403
// HttpError, surfaced by the catch in registerRoutes.
assertNotSuspended(actor.user);
assertVerifiedAccount(actor.user);
// Expand `~`/`~/...` against the authenticated actor's username.
+4 -2
View File
@@ -54,9 +54,11 @@ export interface Actor {
* Session reference when authenticated via a session token (user actors)
* or an app-under-user token that carries a session. Absent for system,
* raw-app, and pure access-token actors. Used for session introspection
* and targeted logout.
* and targeted logout. `kind` mirrors the session row's kind (e.g.
* 'web', 'app', 'worker') so callers can gate on how the credential
* was minted without an extra session lookup.
*/
session?: { uid: string } | null;
session?: { uid: string; kind?: string | null } | null;
}
/** UUID of the baked-in system user (see 0025 seed migration). */
@@ -55,13 +55,15 @@ function readDeviceFingerprint(req: {
* Stamp request-scoped fingerprints onto `req` so any downstream gate, handler,
* or service (via the ALS `Context.get('req')`) can read them without recomputing:
*
* - `req.networkFingerprint` — always set; a coarse IP+headers hash (the same
* value the rate limiter keys on). Server-derived, so it can't be forged
* away, but it's coarse (shared behind NAT/VPN, rotates with UA).
* - `req.networkFingerprint` — always set; a coarse IP+headers hash (the
* anchor of the rate limiter's default key). Server-derived, so it can't be
* forged away, but it's coarse (shared behind NAT/VPN, rotates with UA).
* - `req.deviceFingerprint` — set only when the client supplied a well-shaped
* device fingerprint (ThumbmarkJS hash) in the body or the
* `x-puter-device-fingerprint` header; `undefined` otherwise. Client-supplied
* and spoofable, but stable per real device across IP rotation.
* and spoofable, but stable per real device across IP rotation. The rate
* limiter's 'fingerprint' strategy appends it to the network hash so each
* device behind a shared network gets its own bucket.
*
* Install AFTER the body parsers (so the body fingerprint is readable) and
* before `requestContext` (so the snapshot into ALS already carries them).
@@ -407,6 +407,113 @@ describe('adminOnlyGate', () => {
}),
).toBeUndefined();
});
// -- Root-token requirement --
//
// Admin endpoints require a root token (an actor with no app anywhere
// in its token chain), so a third-party app an admin authorized can't
// reach them on the admin's behalf.
it('admits an admin acting via a session (root token)', () => {
const got = runGate(adminOnlyGate(), {
actor: { user: { uuid: 'u-1', username: 'admin' } },
});
expect(got).toBeUndefined();
});
it("admits an admin's full-access PAT (still a root token — no app)", () => {
const got = runGate(adminOnlyGate(), {
actor: {
user: { uuid: 'u-1', username: 'admin' },
accessToken: {
uid: 'tok-1',
issuer: { user: { uuid: 'u-1', username: 'admin' } },
fullAccess: true,
},
},
});
expect(got).toBeUndefined();
});
it('rejects an admin acting through an app with 403 (not a root token)', () => {
const got = runGate(adminOnlyGate(), {
actor: {
user: { uuid: 'u-1', username: 'admin' },
app: { uid: 'app-1' },
},
});
expectHttpError(got, 403, 'forbidden');
});
it('rejects an admin access token issued through an app (app in the token chain)', () => {
// Access-token actors carry their app on `accessToken.issuer.app`,
// not top-level `actor.app` — the root-token check must walk the
// chain, not just the top level.
const got = runGate(adminOnlyGate(), {
actor: {
user: { uuid: 'u-1', username: 'admin' },
accessToken: {
uid: 'tok-1',
issuer: {
user: { uuid: 'u-1', username: 'admin' },
app: { uid: 'app-1' },
},
},
},
});
expectHttpError(got, 403, 'forbidden');
});
it('rejects an app-issued access token even when appGated', () => {
// The appGated deferral only applies to direct app-under-user
// actors: `allowedAppIdsGate` reads top-level `actor.app` and would
// pass a chain-only app straight through, so it must not be
// deferred to.
const got = runGate(adminOnlyGate([], { appGated: true }), {
actor: {
user: { uuid: 'u-1', username: 'admin' },
accessToken: {
uid: 'tok-1',
issuer: {
user: { uuid: 'u-1', username: 'admin' },
app: { uid: 'app-1' },
},
},
},
});
expectHttpError(got, 403, 'forbidden');
});
it('admits an admin acting through an app when appGated (allowedAppIdsGate then decides)', () => {
// On an appId-gated route the root-token check is deferred to
// `allowedAppIdsGate`; this gate must let the app actor through.
const got = runGate(adminOnlyGate([], { appGated: true }), {
actor: {
user: { uuid: 'u-1', username: 'admin' },
app: { uid: 'app-1' },
},
});
expect(got).toBeUndefined();
});
it('still admits a root token when appGated', () => {
const got = runGate(adminOnlyGate([], { appGated: true }), {
actor: { user: { uuid: 'u-1', username: 'admin' } },
});
expect(got).toBeUndefined();
});
it('applies the username check before the root-token check', () => {
// A non-admin acting through an app is rejected for being non-admin,
// regardless of the app scope.
const got = runGate(adminOnlyGate(), {
actor: {
user: { uuid: 'u-1', username: 'random-user' },
app: { uid: 'app-1' },
},
});
expectHttpError(got, 403, 'forbidden');
});
});
// ── requireVerifiedGate ─────────────────────────────────────────────
+44 -10
View File
@@ -18,6 +18,7 @@
*/
import type { Request, RequestHandler } from 'express';
import { effectiveActorApp } from '../../actor';
import { HttpError } from '../HttpError';
import { assertVerifiedEmail } from '../verifiedEmail';
@@ -93,12 +94,10 @@ export const requireAuthGate = (): RequestHandler => {
next(rejectAuth(req));
return;
}
if (req.actor.user.suspended) {
next(
new HttpError(403, 'Account suspended', {
legacyCode: 'forbidden',
}),
);
try {
assertNotSuspended(req.actor.user);
} catch (err) {
next(err);
return;
}
next();
@@ -184,13 +183,24 @@ export const DEFAULT_ADMIN_USERNAMES = ['admin', 'system'] as const;
* the supplied extras. Extras are *additional* allowed users on top of the
* built-in pair, not a replacement for it.
*
* Implies `requireAuth`. Does *not* imply `requireUserActor` — admin
* endpoints are callable via an admin's access token or app-under-user
* actor; combine with `requireUserActor` explicitly if a route must be
* restricted to browser sessions.
* Also requires a *root token* — an actor with no app anywhere in its token
* chain (see `effectiveActorApp`) — so a third-party app an admin has
* authorized can't reach admin endpoints on the admin's behalf. The one
* exception is `appGated`: on a route that is also appId-gated
* (`allowedAppIds`), a direct app-under-user actor is deferred to
* `allowedAppIdsGate`, so the net effect there is "a root token OR a token
* scoped to an allowed app". Access tokens issued through an app are
* rejected even then — `allowedAppIdsGate` only sees top-level `actor.app`
* and would otherwise wave them through.
*
* Implies `requireAuth`. Does *not* imply `requireUserActor` — a root token
* still includes an admin's full-access personal access token, not only
* browser sessions; combine with `requireUserActor` explicitly if a route
* must be restricted to browser sessions.
*/
export const adminOnlyGate = (
extras: readonly string[] = [],
opts: { appGated?: boolean } = {},
): RequestHandler => {
// Match the case-insensitivity guarantee of the username column
// (MySQL: ascii_general_ci; SQLite: idx_user_username_nocase). Comparing
@@ -209,6 +219,20 @@ export const adminOnlyGate = (
);
return;
}
// Root-token requirement: reject actors carrying an app anywhere in
// their token chain — app-under-user, or an access token issued
// through an app. A direct app-under-user actor is deferred to
// `allowedAppIdsGate` when the route is appId-gated; chain-only apps
// are rejected even then, since that gate can't see them.
const chainApp = req.actor ? effectiveActorApp(req.actor) : null;
if (chainApp && !(opts.appGated && req.actor?.app?.uid)) {
next(
new HttpError(403, 'Only admins may request this resource', {
legacyCode: 'forbidden',
}),
);
return;
}
next();
};
};
@@ -312,6 +336,16 @@ export const assertVerifiedAccount = (
}
};
export const assertNotSuspended = (
user: { suspended?: unknown } | undefined,
): void => {
if (user?.suspended) {
throw new HttpError(403, 'Account suspended', {
legacyCode: 'forbidden',
});
}
};
/**
* Reject unless the actor is acting through one of the named apps.
* App-under-user actors are permitted iff `actor.app.uid` is in the allowList;
@@ -0,0 +1,150 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { RequestHandler } from 'express';
import { Readable } from 'node:stream';
import type { puterClients } from '../../../clients';
import type { puterServices } from '../../../services';
import type { puterStores } from '../../../stores';
import type { IConfig, LayerInstances } from '../../../types';
interface Layers {
clients: LayerInstances<typeof puterClients>;
stores: LayerInstances<typeof puterStores>;
services: LayerInstances<typeof puterServices>;
}
// Local analogue of the production `<name>.puter.work` worker domain. Requests
// to `<name>.workers.puter.localhost` are dispatched into a Miniflare instance
// by `LocalWorkerService`, which mirrors the real Cloudflare dispatch path.
const WORKER_HOST_SUFFIX = 'workers.puter.localhost';
// Minimal WHATWG-Response shape we consume from Miniflare's `dispatchFetch`.
// It isn't the Node global `Response`, so we type it structurally rather than
// importing Miniflare's classes into the HTTP layer.
interface FetchResponse {
status: number;
headers: { forEach(cb: (value: string, key: string) => void): void };
body: ReadableStream<Uint8Array> | null;
}
function normalizeHost(value: string | undefined | null): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim().toLowerCase().replace(/^\./, '');
if (!trimmed) return null;
return trimmed.split(':')[0] || null;
}
// `<name>.workers.puter.localhost` → `name`. Returns null for the bare zone or
// any host outside it. Flip this if your dev DNS puts the name elsewhere.
function workerNameFromHost(host: string): string | null {
if (host === WORKER_HOST_SUFFIX) return null;
if (!host.endsWith(`.${WORKER_HOST_SUFFIX}`)) return null;
const prefix = host.slice(0, host.length - WORKER_HOST_SUFFIX.length - 1);
return prefix.split('.')[0] || null;
}
// Express (Node) request → WHATWG Request the Worker's `fetch(request)` sees.
// Must run BEFORE any body-parsing middleware so `req` is still an unconsumed
// stream; otherwise the Worker gets an empty body on POST/PUT.
function toFetchRequest(req: Parameters<RequestHandler>[0]): Request {
const url = `http://${req.headers.host ?? WORKER_HOST_SUFFIX}${req.originalUrl}`;
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
for (const v of value) headers.append(key, v);
} else if (value != null) {
headers.set(key, value);
}
}
const method = (req.method ?? 'GET').toUpperCase();
const hasBody = method !== 'GET' && method !== 'HEAD';
return new Request(url, {
method,
headers,
// `duplex: 'half'` is required by undici whenever a stream body is set.
body: hasBody ? (Readable.toWeb(req) as ReadableStream) : undefined,
...(hasBody ? { duplex: 'half' } : {}),
} as RequestInit);
}
// WHATWG Response from the Worker → Express response.
function sendFetchResponse(
res: Parameters<RequestHandler>[1],
response: FetchResponse,
): void {
res.status(response.status);
response.headers.forEach((value, key) => {
// Node manages framing headers itself; forwarding them corrupts the
// response (double content-length, stale transfer-encoding).
const lower = key.toLowerCase();
if (lower === 'content-length' || lower === 'transfer-encoding') return;
res.setHeader(key, value);
});
if (!response.body) {
res.end();
return;
}
const nodeStream = Readable.fromWeb(response.body as never);
nodeStream.on('error', () => res.destroy());
nodeStream.pipe(res);
}
/**
* Serves local Workers on `*.workers.puter.localhost` by dispatching into
* Miniflare via `LocalWorkerService`. No-op unless `config.workers.localServer`
* is set — production keeps hitting real Cloudflare through `WorkerDriver`.
*
* Mount this BEFORE the body-parsing middleware in `server.ts` so the Worker
* receives the raw request stream.
*/
export const createLocalWorkerProxyMiddleware = (
config: IConfig,
layers: Layers,
): RequestHandler => {
if (!config.workers?.localServer) {
return (_req, _res, next) => next();
}
const localWorkerService = layers.services.localworkerservice;
return async (req, res, next) => {
const host = normalizeHost(req.hostname);
if (!host) return next();
const workerName = workerNameFromHost(host);
if (!workerName) return next();
try {
const fetchRequest = toFetchRequest(req);
const response = (await localWorkerService.cfCallLocal(
workerName,
fetchRequest,
)) as unknown as FetchResponse;
sendFetchResponse(res, response);
} catch (err) {
next(err);
}
};
};
+48 -11
View File
@@ -18,6 +18,7 @@
*/
import crypto from 'node:crypto';
import { withSpan } from '../../../util/span.js';
import { HttpError } from '../HttpError.js';
/**
@@ -256,8 +257,32 @@ async function acquireKvConcurrent(kv, key, limit) {
// limiting — so route / driver code never has to reason about which
// backend is wired for which mode.
/**
* Wrap a backend pair so every rate / acquire call runs inside a span
* tagged with the backend name. Applied at registration, so all gates
* (route middleware, driver helpers, imperative checks) are covered.
*/
function instrumentBackendPair(name, pair) {
const attrs = { 'rate_limit.backend': name };
return {
rate: (key, limit, windowMs) =>
withSpan('rate_limit.check', attrs, () =>
pair.rate(key, limit, windowMs),
),
acquire: (key, limit) =>
withSpan('rate_limit.acquire', attrs, () =>
pair.acquire(key, limit),
),
};
}
const memoryBackendPair = instrumentBackendPair('memory', {
rate: checkMemory,
acquire: acquireMemoryConcurrent,
});
const backends = {
memory: { rate: checkMemory, acquire: acquireMemoryConcurrent },
memory: memoryBackendPair,
};
let defaultBackendName = 'memory';
@@ -295,19 +320,19 @@ export function configureRateLimit({
} = {}) {
// Reset (test reconfigure clears stale wiring).
for (const name of Object.keys(backends)) delete backends[name];
backends.memory = { rate: checkMemory, acquire: acquireMemoryConcurrent };
backends.memory = memoryBackendPair;
if (redis) {
backends.redis = {
backends.redis = instrumentBackendPair('redis', {
rate: (key, limit, windowMs) =>
checkRedis(redis, key, limit, windowMs),
acquire: (key, limit) => acquireRedisConcurrent(redis, key, limit),
};
});
}
if (kv) {
backends.kv = {
backends.kv = instrumentBackendPair('kv', {
rate: (key, limit, windowMs) => checkKv(kv, key, limit, windowMs),
acquire: (key, limit) => acquireKvConcurrent(kv, key, limit),
};
});
}
meteringService = metering ?? null;
@@ -351,9 +376,10 @@ function resolveBackend(name) {
* Build a rate-limit key from the request.
*
* Strategies:
* 'fingerprint' — IP + User-Agent hash (default). Good for
* unauthenticated endpoints where the same IP may
* serve many users (offices, VPNs).
* 'fingerprint' — network hash (IP + headers), refined by the client's
* device fingerprint when one was supplied (default).
* Good for unauthenticated endpoints where the same
* IP may serve many users (offices, VPNs).
* 'ip' — bare IP. Simpler but coarser.
* 'user' — actor UUID. Use for authenticated endpoints where
* you want per-account limits regardless of IP.
@@ -395,7 +421,7 @@ function ip(req) {
/**
* A coarse network fingerprint for a request: a short hash of the (proxy-aware)
* IP plus the headers a client can't trivially vary per-request without also
* changing how the request looks. Used as the default rate-limit key here, and
* changing how the request looks. Anchors the default rate-limit key here, and
* exported so the global fingerprint middleware can stamp the identical value
* on `req.networkFingerprint` (one key space shared by both).
*/
@@ -413,8 +439,19 @@ export function computeNetworkFingerprint(req) {
.slice(0, 16);
}
/**
* The device fingerprint (validated and stamped by the fingerprint middleware)
* refines the bucket so devices behind one NAT don't crowd each other's limit.
* It stays anchored to the network hash because the value is client-supplied:
* alone it could be spoofed to drain another device's bucket, and rotating it
* to mint fresh buckets is caught by the same stacked 'ip' backstop that
* catches User-Agent rotation.
*/
function fingerprint(req) {
return computeNetworkFingerprint(req);
const network = req.networkFingerprint ?? computeNetworkFingerprint(req);
return req.deviceFingerprint
? `${network}:${req.deviceFingerprint}`
: network;
}
// -- Route middleware ------------------------------------------------
@@ -67,6 +67,7 @@ describe('rateLimitGate — memory backend (default)', () => {
actor: init.actor,
route: init.route,
socket: init.socket ?? { remoteAddress: '1.2.3.4' },
deviceFingerprint: init.deviceFingerprint,
});
it('admits up to `limit` hits and rejects the next one with 429', async () => {
@@ -140,6 +141,62 @@ describe('rateLimitGate — memory backend (default)', () => {
expect(isHttpError(re)).toBe(true);
});
it("'fingerprint' (default) gives each device behind one network its own bucket", async () => {
// Same IP and identical headers (a NAT'd office of look-alike
// machines) but distinct device fingerprints (stamped by the
// fingerprint middleware) → independent buckets, so one device
// hitting its limit doesn't block the whole network.
const opts = { limit: 1, window: 60_000, scope: 'mem-fp-device' };
const shared = {
ip: '5.6.7.8',
headers: { 'user-agent': 'shared-browser' },
};
expect(
await runGate(
opts,
makeReq({ ...shared, deviceFingerprint: 'device-alice-01' }),
),
).toBeUndefined();
expect(
await runGate(
opts,
makeReq({ ...shared, deviceFingerprint: 'device-bob-02' }),
),
).toBeUndefined();
// Same device again → its own bucket is full.
const re = await runGate(
opts,
makeReq({ ...shared, deviceFingerprint: 'device-alice-01' }),
);
expect(isHttpError(re)).toBe(true);
});
it('device fingerprint stays anchored to the network — spoofing a value from another IP cannot drain its bucket', async () => {
const opts = { limit: 1, window: 60_000, scope: 'mem-fp-anchor' };
const fp = 'device-victim-01';
// Victim exhausts their bucket from their own network.
expect(
await runGate(
opts,
makeReq({ ip: '10.0.0.1', deviceFingerprint: fp }),
),
).toBeUndefined();
const re = await runGate(
opts,
makeReq({ ip: '10.0.0.1', deviceFingerprint: fp }),
);
expect(isHttpError(re)).toBe(true);
// Attacker replays the same device fingerprint from elsewhere:
// different network hash → different bucket, and the victim's
// bucket is untouched.
expect(
await runGate(
opts,
makeReq({ ip: '99.99.99.99', deviceFingerprint: fp }),
),
).toBeUndefined();
});
// Stacked gates, mirroring the materializer's handling of a
// `rateLimit: [...]` array (each entry is its own gate; a rejection
// short-circuits the chain). This is the credential-endpoint pattern:
+13 -5
View File
@@ -121,9 +121,15 @@ export interface RouteOptions {
* extras in this array. `true` means just `admin`/`system`; an array adds
* to that pair (does not replace it). Implies `requireAuth`.
*
* Does NOT imply `requireUserActor` — admin endpoints accept an admin's
* access-token or app-under-user actor. Combine with `requireUserActor`
* to restrict to browser sessions.
* Also requires a *root token* (an actor with no app anywhere in its
* token chain), so an admin acting through a third-party app can't reach
* the route. Pair with `allowedAppIds` to make an admin route reachable
* by specific apps: the combination admits a root token OR a token
* scoped to an allowed app.
*
* Does NOT imply `requireUserActor` — a root token still includes an
* admin's full-access personal access token, not only browser sessions.
* Combine with `requireUserActor` to restrict to browser sessions.
*/
adminOnly?: boolean | string[];
@@ -210,8 +216,10 @@ export interface RouteOptions {
* request identity.
*
* `key` controls how requests are bucketed:
* - `'fingerprint'` (default) — IP + User-Agent hash. Safe for
* shared IPs (offices, VPNs).
* - `'fingerprint'` (default) — network hash (IP + headers),
* refined by the client's device fingerprint when one was
* supplied. Safe for shared IPs (offices, VPNs): each device
* gets its own bucket instead of the whole network sharing one.
* - `'ip'` — bare IP address.
* - `'user'` — actor's user ID. Use for authenticated routes
* where you want per-account limits.
@@ -23,6 +23,74 @@ import type { IChatModel } from '../../types.js';
// Hardcoded from https://models.dev/api.json
export const OPEN_AI_MODELS: IChatModel[] = [
{
puterId: 'openai:openai/gpt-5.6-sol',
id: 'gpt-5.6-sol',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2026-02-16',
aliases: [
'gpt-5.6',
'gpt-5.6-sol',
'openai/gpt-5.6',
'openai/gpt-5.6-sol',
],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 500,
cached_tokens: 50,
completion_tokens: 3000,
},
context: 1_050_000,
max_tokens: 128_000,
responses_api_only: true,
},
{
puterId: 'openai:openai/gpt-5.6-terra',
id: 'gpt-5.6-terra',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2026-02-16',
aliases: ['gpt-5.6-terra', 'openai/gpt-5.6-terra'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 250,
cached_tokens: 25,
completion_tokens: 1500,
},
context: 1_050_000,
max_tokens: 128_000,
responses_api_only: true,
},
{
puterId: 'openai:openai/gpt-5.6-luna',
id: 'gpt-5.6-luna',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2026-02-16',
aliases: ['gpt-5.6-luna', 'openai/gpt-5.6-luna'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 100,
cached_tokens: 10,
completion_tokens: 600,
},
context: 1_050_000,
max_tokens: 128_000,
responses_api_only: true,
},
{
puterId: 'openai:openai/gpt-5.5',
id: 'gpt-5.5-2026-04-23',
@@ -21,6 +21,27 @@ import type { IChatModel } from '../../types.js';
// Hardcoded from https://models.dev/api.json
export const XAI_MODELS: IChatModel[] = [
{
puterId: 'x-ai:x-ai/grok-4.5',
id: 'grok-4.5',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
release_date: '2026-07-08',
name: 'Grok 4.5',
aliases: ['x-ai/grok-4.5', 'grok-4.5-latest'],
context: 500_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
completion_tokens: 600,
cached_tokens: 50,
},
max_tokens: 30_000,
},
{
puterId: 'x-ai:x-ai/grok-beta',
id: 'grok-beta',
+9 -1
View File
@@ -1117,7 +1117,15 @@ export class AppDriver extends PuterDriver {
const subdomain = this.#extractPuterHostedSubdomain(indexUrl);
if (!subdomain) return;
const row = await this.stores.subdomain.getBySubdomain(subdomain);
let row = await this.stores.subdomain.getBySubdomain(subdomain);
if (!row) {
// Deploys create the subdomain and immediately point the app
// at it, so a replica or peer-cache miss here would wrongly
// refuse the owner. Confirm against the primary before failing.
row = await this.stores.subdomain.getBySubdomain(subdomain, {
primary: true,
});
}
if (!row || row.user_id !== user.id) {
throw new HttpError(400, 'Subdomain not owned by user', {
legacyCode: 'subdomain_not_owned',
@@ -1081,3 +1081,98 @@ describe('AppDriver alias-group index_url merge', () => {
expect(first.uid).not.toBe(second.uid);
});
});
// ── hosted-subdomain ownership check ────────────────────────────────
//
// `#ensurePuterSiteSubdomainIsOwned` gates puter-hosted index_urls on a
// subdomain row the caller owns. Deploy flows create that row and point
// the app at it in back-to-back requests, so the check must tolerate a
// replica/cache miss by confirming against the primary before refusing.
describe('AppDriver hosted-subdomain ownership check', () => {
const hostedUrl = (sub: string) => `https://${sub}.site.puter.localhost/`;
it('accepts a hosted index_url when the subdomain row has not reached the replica yet', async () => {
const { actor, userId } = await makeUser();
const sub = uniqueName('deploy');
await server.stores.subdomain.create({ userId, subdomain: sub });
// Simulate a peer node with a lagging replica: no cache entry for
// the row, and replica reads (`read`) that don't see it yet while
// primary reads (`pread`) do. Sqlite's `pread` delegates to
// `this.read`, so pin it to the original before stubbing `read`.
await server.clients.redis.del(`subdomains:name:${sub}`);
const db = server.clients.db as unknown as {
read: (q: string, p?: unknown[]) => Promise<unknown[]>;
pread: (q: string, p?: unknown[]) => Promise<unknown[]>;
};
const originalRead = db.read.bind(server.clients.db);
const hadOwnPread = Object.prototype.hasOwnProperty.call(db, 'pread');
db.pread = async (query: string, params?: unknown[]) =>
originalRead(query, params);
db.read = async (query: string, params?: unknown[]) =>
query.includes('FROM `subdomains`') && (params ?? []).includes(sub)
? []
: originalRead(query, params);
try {
const result = await withActor(actor, () =>
driver.create({
object: {
name: uniqueName('app'),
title: 'Deployed App',
index_url: hostedUrl(sub),
},
}),
);
expect(result.uid).toEqual(expect.any(String));
// The primary hit must also heal the stale cache: a normal
// lookup now resolves from cache even though the replica
// still misses.
const healed =
await server.stores.subdomain.getBySubdomain(sub);
expect(healed?.subdomain).toBe(sub);
} finally {
delete (db as { read?: unknown }).read;
if (!hadOwnPread) delete (db as { pread?: unknown }).pread;
}
});
it('rejects a hosted index_url whose subdomain does not exist anywhere', async () => {
const { actor } = await makeUser();
await expect(
withActor(actor, () =>
driver.create({
object: {
name: uniqueName('app'),
title: 'x',
index_url: hostedUrl(uniqueName('ghost')),
},
}),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it("rejects a hosted index_url pointing at another user's subdomain", async () => {
const owner = await makeUser();
const intruder = await makeUser();
const sub = uniqueName('theirs');
await server.stores.subdomain.create({
userId: owner.userId,
subdomain: sub,
});
await expect(
withActor(intruder.actor, () =>
driver.create({
object: {
name: uniqueName('app'),
title: 'x',
index_url: hostedUrl(sub),
},
}),
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
@@ -244,9 +244,11 @@ export class SubdomainDriver extends PuterDriver {
if (object.domain !== undefined)
patch.domain = object.domain != null ? String(object.domain) : null;
const updated = await this.stores.subdomain.update(row.uuid, patch, {
userId: row.user_id,
});
const updated = await this.stores.subdomain.update(
String(row.uuid),
patch,
{ userId: row.user_id as number },
);
const [shaped] = await this.#hydrateRows(
updated ? [updated as Record<string, unknown>] : [],
);
@@ -351,8 +353,8 @@ export class SubdomainDriver extends PuterDriver {
}
await this.#checkWriteAccess(row, actor);
await this.stores.subdomain.deleteByUuid(row.uuid, {
userId: row.user_id,
await this.stores.subdomain.deleteByUuid(String(row.uuid), {
userId: row.user_id as number,
});
try {
+34 -12
View File
@@ -25,6 +25,7 @@ import { Context } from '../../core/context.js';
import { HttpError, type LegacyErrorCodes } from '../../core/http/HttpError.js';
import { assertVerifiedEmail } from '../../core/http/verifiedEmail.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import type { SubdomainRow } from '../../stores/subdomain/SubdomainStore.js';
import { PuterDriver } from '../types.js';
import { loadFileInput } from '../util/fileInput.js';
@@ -33,6 +34,7 @@ const WORKER_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
const MAX_WORKERS_PER_USER = 100;
const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
const WORKER_SUBDOMAIN_PREFIX = 'workers.puter.';
let USE_LOCAL_WORKERD = false;
// -- Preamble --------------------------------------------------------
//
@@ -66,6 +68,16 @@ try {
preambleError = true;
}
/**
* The puter.js/router preamble prepended to every worker's source before
* deploy. Exposed so the local-workerd path (`LocalWorkerService`) can build
* the same `preamble + sourceCode` script when it lazily re-deploys a worker
* into Miniflare after a server restart.
*/
export function getWorkerPreamble(): string {
return preamble;
}
/**
* Driver exposing the `workers` interface — Cloudflare Workers
* deployment, lifecycle, and file-path queries.
@@ -100,6 +112,8 @@ export class WorkerDriver extends PuterDriver {
'[workers] preamble not build but workers configured to be enabled. Halting start',
);
}
} else if (cfg.localServer) {
USE_LOCAL_WORKERD = true;
}
this.#subscribeHotReload();
}
@@ -300,7 +314,7 @@ export class WorkerDriver extends PuterDriver {
const actor = this.#requireActor();
const workerName = args.workerName as string | undefined;
let rows: Array<Record<string, unknown>>;
let rows: SubdomainRow[];
if (typeof workerName === 'string' && workerName.length > 0) {
const sub = await this.stores.subdomain.getBySubdomain(
`${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
@@ -365,6 +379,13 @@ export class WorkerDriver extends PuterDriver {
authorization: string,
code: string,
): Promise<Record<string, unknown>> {
if (USE_LOCAL_WORKERD) {
return this.services.localworkerservice.cfDeployLocal(
workerName,
authorization,
code,
);
}
const cfg = this.#workerConfig();
const metadata = JSON.stringify({
body_part: 'swCode',
@@ -430,6 +451,9 @@ export class WorkerDriver extends PuterDriver {
}
async #cfDelete(workerName: string): Promise<Record<string, unknown>> {
if (USE_LOCAL_WORKERD) {
return this.services.localworkerservice.cfDeleteLocal(workerName);
}
const cfg = this.#workerConfig();
const res = await fetch(`${this.#cfBaseUrl}/scripts/${workerName}/`, {
method: 'DELETE',
@@ -455,7 +479,7 @@ export class WorkerDriver extends PuterDriver {
#requireCfConfig(): void {
const cfg = this.#workerConfig();
if (!cfg.XAUTHKEY || !cfg.ACCOUNTID) {
if ((!cfg.XAUTHKEY || !cfg.ACCOUNTID) && !cfg.localServer) {
throw new HttpError(503, 'Cloudflare Workers not configured', {
legacyCode: 'response_timeout',
});
@@ -472,7 +496,7 @@ export class WorkerDriver extends PuterDriver {
}
#checkWorkerWriteAccess(
row: Record<string, unknown>,
row: SubdomainRow,
actor: Actor & { user: { id: number } },
errorStatus: number,
errorMessage: string,
@@ -527,7 +551,7 @@ export class WorkerDriver extends PuterDriver {
// while worker subdomains are keyed to the numeric fsentries.id.
#subscribeHotReload(): void {
if (!this.#cfBaseUrl) return; // CF not configured — skip
if (!this.#cfBaseUrl && !USE_LOCAL_WORKERD) return;
this.clients.event.on(
'fs.write.file',
@@ -715,14 +739,12 @@ export class WorkerDriver extends PuterDriver {
);
}
async #listWorkerRowsForEntry(
entry: FSEntry,
): Promise<Array<Record<string, unknown>>> {
async #listWorkerRowsForEntry(entry: FSEntry): Promise<SubdomainRow[]> {
const workerSubs = await this.stores.subdomain.listByUserIdAndPrefix(
entry.userId,
WORKER_SUBDOMAIN_PREFIX,
);
return workerSubs.filter((r: Record<string, unknown>) => {
return workerSubs.filter((r) => {
return (
String(r.root_dir_id) === String(entry.id) ||
String(r.root_dir_id) === String(entry.uuid) ||
@@ -734,17 +756,17 @@ export class WorkerDriver extends PuterDriver {
async #listWorkerRowsUnderPath(
userId: number,
parentPath: string,
): Promise<Array<Record<string, unknown>>> {
): Promise<SubdomainRow[]> {
const workerSubs = await this.stores.subdomain.listByUserIdAndPrefix(
userId,
WORKER_SUBDOMAIN_PREFIX,
);
const rootDirIds = workerSubs
.map((r: Record<string, unknown>) => r.root_dir_id)
.map((r) => r.root_dir_id)
.filter((id): id is number => typeof id === 'number');
const entriesById =
await this.stores.fsEntry.getEntriesByIds(rootDirIds);
return workerSubs.filter((row: Record<string, unknown>) => {
return workerSubs.filter((row) => {
const rootDirId = row.root_dir_id;
if (typeof rootDirId !== 'number') return false;
const entry = entriesById.get(rootDirId);
@@ -761,7 +783,7 @@ export class WorkerDriver extends PuterDriver {
}
async #deleteWorkerForSourceRow(
row: Record<string, unknown>,
row: SubdomainRow,
userId: number,
): Promise<void> {
const workerFullName = String(row.subdomain ?? '');
+19
View File
@@ -19,6 +19,7 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { isSpanContextValid, trace } from '@opentelemetry/api';
import { puterClients } from './clients';
import { puterControllers } from './controllers';
import { puterDrivers } from './drivers';
@@ -26,6 +27,7 @@ import { PuterServer } from './server';
import { puterServices } from './services';
import { puterStores } from './stores';
import type { IConfig } from './types';
import { installJsonConsole } from './util/jsonConsole.js';
// Config resolution order:
// 1. `process.env.PUTER_CONFIG_PATH` — absolute path to a config file. Used
@@ -167,6 +169,23 @@ const loadConfig = (): IConfig => {
// if called directly, start the server
if (require.main === module) {
const config = loadConfig();
// Structured logging: when `log_format: "json"`, replace the global console
// so each call emits one JSON line (level, timestamp, msg, and the active
// trace id) — one event per call, so a line-oriented log collector can't
// split stack traces across events. Installed here rather than in the OTel
// preload so it applies even when telemetry is disabled; the trace id is
// simply absent when no span is active.
if (config.log_format === 'json') {
installJsonConsole({
getTraceContext: () => {
const ctx = trace.getActiveSpan()?.spanContext();
if (!ctx || !isSpanContextValid(ctx)) return undefined;
return { traceId: ctx.traceId, spanId: ctx.spanId };
},
});
}
const server = new PuterServer(
config,
puterClients,
+1
View File
@@ -79,6 +79,7 @@
"@types/busboy": "^1.5.4",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.0.0",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.6.1",
"@types/validator": "^13.15.10",
"chai": "^4.3.7",
+24 -5
View File
@@ -62,6 +62,7 @@ import {
createUserSubdomainRedirect,
createNativeAppStatic,
} from './core/http/middleware/hostRedirects';
import { createLocalWorkerProxyMiddleware } from './core/http/middleware/localWorkerProxy';
import { createPuterSiteMiddleware } from './core/http/middleware/puterSite';
import { PuterRouter } from './core/http/PuterRouter';
import { createRouteLifecycleMiddleware } from './core/http/routeLifecycle';
@@ -454,6 +455,18 @@ export class PuterServer {
res.sendStatus(200);
});
// -- Local Worker proxy (*.workers.puter.localhost) ----------
// Dev-only Miniflare dispatch, gated on `config.workers.localServer`.
// Mounted BEFORE body parsing so the Worker receives the raw request
// stream; no-op in production (real Cloudflare via WorkerDriver).
this.#app.use(
createLocalWorkerProxyMiddleware(this.#config, {
clients: this.clients,
stores: this.stores,
services: this.services,
}),
);
// -- Body parsing (JSON + text-as-json shim) -----------------
const captureRawBody: NonNullable<
Parameters<typeof express.json>[0]
@@ -865,10 +878,12 @@ export class PuterServer {
// carry, so it works for either actor shape.
//
// `adminOnly` also does NOT imply `requireUserActor`: admin endpoints
// should be callable from scripts/automation using an admin's access
// token, not only from browser sessions. `adminOnlyGate` gates on
// `actor.user.username`, which is populated for access-token and
// app-under-user actors alike.
// stay callable from scripts/automation using an admin's full-access
// token, not only from browser sessions — both are root tokens.
// Beyond the username check, `adminOnlyGate` requires a root token
// (rejecting an admin acting through a third-party app) unless the
// route is also appId-gated, in which case `allowedAppIdsGate` governs
// which apps may pass.
if (opts.requireUserActor) {
mwChain.push(
requireUserActorGate({
@@ -879,7 +894,11 @@ export class PuterServer {
if (opts.adminOnly) {
const extras = Array.isArray(opts.adminOnly) ? opts.adminOnly : [];
mwChain.push(adminOnlyGate(extras));
mwChain.push(
adminOnlyGate(extras, {
appGated: Boolean(opts.allowedAppIds),
}),
);
}
if (opts.allowedAppIds) {
@@ -32,6 +32,8 @@ const RECOMMENDED_APP_NAMES = [
'app-center',
'dev-center',
'butler',
'calculator',
'calendar',
'code',
'music-player',
'contacts',
+8 -2
View File
@@ -28,6 +28,7 @@ import {
import type { UserRow } from '../../stores/user/UserStore';
import type { LayerInstances } from '../../types';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { Span } from '../../util/span.js';
import type { puterServices } from '../index';
import { PuterService } from '../types';
import { FULL_API_ACCESS } from '../permission/consts';
@@ -143,6 +144,7 @@ export class AuthService extends PuterService {
return { authId: decoded.auth_id };
}
@Span('auth.authenticate')
async authenticate(
token: string,
ctx: { ip?: string; userAgent?: string } = {},
@@ -1965,7 +1967,9 @@ export class AuthService extends PuterService {
#buildUserActor(user: UserRow, session: SessionRow | null): Actor {
return {
user: this.#actorUserFromRow(user),
session: session ? { uid: session.uuid } : null,
session: session
? { uid: session.uuid, kind: session.kind ?? null }
: null,
};
}
@@ -1980,7 +1984,9 @@ export class AuthService extends PuterService {
uid: app.uid,
id: app.id,
},
session: session ? { uid: session.uuid } : null,
session: session
? { uid: session.uuid, kind: session.kind ?? null }
: null,
};
}
}
+17 -2
View File
@@ -424,7 +424,17 @@ export class OIDCService extends PuterService {
providerId: string,
claims: OIDCUserInfo,
referrer?: string | null,
): Promise<{ success: boolean; user?: UserRow; error?: string }> {
): Promise<{
success: boolean;
user?: UserRow;
error?: string;
code?: string;
/**
* Support-correlation id for a vetoed signup (the abuse trail id).
* Safe to show the user; the veto reason in `error` is not.
*/
requestCode?: string;
}> {
if (claims.email_verified === false) {
return {
success: false,
@@ -494,6 +504,10 @@ export class OIDCService extends PuterService {
reputation: null as number | null,
message: null as string | null,
code: null as string | null,
// Stamped by the abuse harness for flagged signups — the id keying
// the persisted decision trail, surfaced to a blocked user as the
// Request Code so support can look the decision up.
trail_id: undefined as string | undefined,
};
try {
await this.clients.event?.emitAndWait(
@@ -508,7 +522,8 @@ export class OIDCService extends PuterService {
return {
success: false,
error: validateEvent.message ?? 'Signup blocked',
...(validateEvent.code ? { code: validateEvent.code } : {}),
code: validateEvent.code ?? 'signup_blocked',
requestCode: validateEvent.trail_id,
};
}
@@ -18,7 +18,7 @@
*/
import axios from 'axios';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { Agent as HttpsAgent } from 'node:https';
import { IBroadcastPeerConfig } from '../../types.js';
import { PuterService } from '../types.js';
@@ -81,15 +81,14 @@ interface IncomingHeaders {
* - Inbound handler ignores POSTs whose `X-Broadcast-Peer-Id` matches
* this server's own peerId (catches misconfigured loopbacks).
*
* No Redis pub/sub here — webhooks are the only transport. Same-cluster
* fan-out is handled by sockets via the Redis streams adapter, so an
* additional Redis channel here would just duplicate work.
*/
export class BroadcastService extends PuterService {
/** peerId → resolved peer config, used for incoming-verify lookup. */
#peersByKey: Record<string, IBroadcastPeerConfig> = {};
/** Subset of peers with `webhook: true`, used for outbound fan-out. */
#webhookPeers: IBroadcastPeerConfig[] = [];
/** Identifier used to tell what server a redis fan-out is coming from. */
#redisSourceId: string = `${this.config.serverId}:${randomUUID()}`;
/** Coalesced outbound events, keyed by serialized shape. */
#outboundEventsByDedupKey = new Map<string, BroadcastEvent>();
@@ -103,12 +102,14 @@ export class BroadcastService extends PuterService {
#webhookHostHeader: string | null = null;
/** Self-signed certs are common between Puter nodes — accept them. */
#webhookHttpsAgent = new HttpsAgent({ rejectUnauthorized: false });
#redisSub: ReturnType<typeof this.clients.redis.duplicate> | null = null;
// -- Lifecycle ---------------------------------------------------
override onServerStart(): void {
this.#loadConfig();
this.#subscribeOutbound();
this.#subscribeRedisOutbound();
}
override async onServerPrepareShutdown(): Promise<void> {
@@ -123,6 +124,11 @@ export class BroadcastService extends PuterService {
} catch (err) {
console.warn('[broadcast] final flush failed', err);
}
if (this.#redisSub) {
await this.#redisSub.unsubscribe('pubsub');
this.#redisSub.quit();
this.#redisSub = null;
}
}
// -- Public API used by BroadcastController ----------------------
@@ -231,8 +237,62 @@ export class BroadcastService extends PuterService {
return { ok: true };
}
// -- Outbound: subscribe + queue + flush ------------------------
#pubsubFanout(key: string, data: unknown, meta: object): void {
const safeMeta = this.#normalizeMeta(meta);
if (safeMeta.from_fanout) return;
this.clients.redis.publish(
'pubsub',
JSON.stringify({
key,
data,
meta: safeMeta,
source: this.#redisSourceId,
}),
);
}
// outer.pubsub.* events will be broadcast to other clusters through webhooks
// pubsub.* will only fan-out to same-cluster nodes.
#subscribeRedisOutbound(): void {
this.#redisSub = this.clients.redis.duplicate();
this.#redisSub.subscribe('pubsub');
this.#redisSub.on('message', (channel: string, message: string) => {
if (channel !== 'pubsub') return;
const parsed = JSON.parse(message);
const { key, data, meta, source } = parsed as {
key: string;
data: unknown;
meta: object;
source: string;
};
if (source === this.#redisSourceId) return;
const safeMeta = this.#normalizeMeta(meta);
this.clients.event.emit(key, data, {
...safeMeta,
from_fanout: true,
// it's not from outside, but mark it as to prevent sending the webhook twice
from_outside: true,
});
});
this.clients.event.on(
'outer.pubsub.*',
(key: string, data: unknown, meta: object) => {
this.#pubsubFanout(key, data, meta);
},
);
this.clients.event.on(
'pubsub.*',
(key: string, data: unknown, meta: object) => {
this.#pubsubFanout(key, data, meta);
},
);
}
// -- Outbound: subscribe + queue + flush ------------------------
// outer.* events will be broadcast to other clusters through webhooks
// outer will NOT automatically sync to same-cluster peers.
#subscribeOutbound(): void {
// Wildcard: every `outer.*` event gets considered for broadcast.
// The handler skips events that came in via webhook (meta.from_outside)
@@ -19,6 +19,7 @@
import { PuterService } from '../types';
import type { SocketService } from '../socket/SocketService';
import { kv } from '../../util/kvSingleton';
/**
* Periodic liveness monitor for the backend. Other services register
@@ -69,6 +70,17 @@ interface HealthStats {
export interface HealthStatus {
ok: boolean;
failed?: string[];
degraded?: string[];
}
export interface GetStatusOptions {
/** Failing check names to drop entirely (healthy if all failures ignored). */
ignore?: string[];
/**
* Failing check names to demote to non-fatal `degraded`. They don't make
* `ok` false, but their presence signals partial health to the caller.
*/
degrade?: string[];
}
export class ServerHealthService extends PuterService {
@@ -128,27 +140,32 @@ export class ServerHealthService extends PuterService {
}
/**
* Current health status. Results are cached in Redis for 5 seconds
* so a busy /healthcheck endpoint doesn't hammer the DB on every hit.
* Current health status of this node. Results are cached in-process
* (kv.js) for 5 seconds so a busy /healthcheck endpoint stays cheap.
* The cache is deliberately per-node — a load balancer polling
* /healthcheck must see the health of the exact node it hit, never
* a status shared with other nodes.
*
* `ignore` names failing states to disregard for this request only,
* letting an orchestrator poll `/healthcheck` while tolerating specific
* known-failing checks; when the remaining failures are all ignored the
* status collapses back to `{ ok: true }`. `degrade` instead demotes
* named failures to a non-fatal `degraded` list — `ok` stays true but
* the caller can see the partial state. Any failure name may be filtered
* this way, including the `draining` lifecycle state. The cached status
* is always the full, unfiltered set — filtering is applied per-request
* after the cache read so it never leaks across callers.
*/
async getStatus(): Promise<HealthStatus> {
if (this.#draining) return { ok: false, failed: ['draining'] };
async getStatus(opts: GetStatusOptions = {}): Promise<HealthStatus> {
const base = this.#draining
? { ok: false, failed: ['draining'] }
: this.#getCachedStatus();
return this.#applyFilters(base, opts.ignore ?? [], opts.degrade ?? []);
}
try {
const cached = await this.clients.redis.get(STATUS_CACHE_KEY);
if (cached) {
try {
return JSON.parse(cached) as HealthStatus;
} catch {
// Cache in invalid state — fall through and overwrite.
}
}
} catch (e) {
console.warn(
'[server-health] status cache read failed:',
(e as Error).message,
);
}
#getCachedStatus(): HealthStatus {
const cached = kv.get(STATUS_CACHE_KEY) as HealthStatus | undefined;
if (cached) return cached;
const failures = this.#collectFailures();
const status: HealthStatus =
@@ -156,23 +173,36 @@ export class ServerHealthService extends PuterService {
? { ok: true }
: { ok: false, failed: failures };
try {
await this.clients.redis.set(
STATUS_CACHE_KEY,
JSON.stringify(status),
'EX',
STATUS_CACHE_TTL_SECONDS,
);
} catch (e) {
console.warn(
'[server-health] status cache write failed:',
(e as Error).message,
);
}
kv.set(STATUS_CACHE_KEY, status, { EX: STATUS_CACHE_TTL_SECONDS });
return status;
}
/**
* Reclassify a status against the per-request `ignore`/`degrade` sets.
* `ignore`d failures are dropped; `degrade`d failures move to a
* non-fatal `degraded` list; anything left stays a hard failure. `ok`
* is false only while hard failures remain. A healthy status is
* returned as-is.
*/
#applyFilters(
status: HealthStatus,
ignore: string[],
degrade: string[],
): HealthStatus {
if (status.ok || !status.failed) return status;
const remaining = status.failed.filter(
(name) => !ignore.includes(name),
);
const degraded = remaining.filter((name) => degrade.includes(name));
const failed = remaining.filter((name) => !degrade.includes(name));
const result: HealthStatus = { ok: failed.length === 0 };
if (failed.length > 0) result.failed = failed;
if (degraded.length > 0) result.degraded = degraded;
return result;
}
#registerDefaultChecks(): void {
const latencyFailMs =
Number(this.config.server_health?.db_liveness_latency_fail_ms) ||
+10 -8
View File
@@ -17,23 +17,24 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ACLService } from './acl/ACLService';
import { AppOriginBlocklistService } from './abuse/AppOriginBlocklistService';
import { ACLService } from './acl/ACLService';
import { AppIconService } from './appIcon/AppIconService';
import { AppPermissionService } from './apps/AppPermissionService';
import { RecommendedAppsService } from './apps/RecommendedAppsService';
import { SuggestedAppsService } from './apps/SuggestedAppsService';
import { AuthService } from './auth/AuthService';
import { BroadcastService } from './broadcast/BroadcastService';
import { NotificationService } from './notification/NotificationService';
import { AppIconService } from './appIcon/AppIconService';
import { DefaultUserService } from './selfhosted/DefaultUserService';
import { PuterHomepageService } from './homepage/PuterHomepageService';
import { OIDCService } from './auth/OIDCService';
import { TokenService } from './auth/TokenService';
import { BroadcastService } from './broadcast/BroadcastService';
import { FSService } from './fs/FSService';
import { MeteringService } from './metering/MeteringService';
import { PermissionService } from './permission/PermissionService';
import { ServerHealthService } from './health/ServerHealthService';
import { PuterHomepageService } from './homepage/PuterHomepageService';
import { LocalWorkerService } from './localworker/LocalWorkerService';
import { MeteringService } from './metering/MeteringService';
import { NotificationService } from './notification/NotificationService';
import { PermissionService } from './permission/PermissionService';
import { DefaultUserService } from './selfhosted/DefaultUserService';
import { SocketService } from './socket/SocketService';
import { SubdomainPermissionService } from './subdomain/SubdomainPermissionService';
import type { IPuterServiceRegistry } from './types';
@@ -106,4 +107,5 @@ export const puterServices = {
// Health comes after socket so its default `socket-initialized`
// check can reference the peer.
health: ServerHealthService,
localworkerservice: LocalWorkerService,
} satisfies IPuterServiceRegistry;
@@ -0,0 +1,193 @@
import { Miniflare, RequestInit as MiniflareRequestInit } from 'miniflare';
import { puterServices } from '..';
import { Actor } from '../../core';
import { loadFileInput } from '../../drivers/util/fileInput';
import { getWorkerPreamble } from '../../drivers/workers/WorkerDriver';
import { puterStores } from '../../stores';
import type { SubdomainRow } from '../../stores/subdomain/SubdomainStore';
import { LayerInstances } from '../../types';
import { PuterService } from '../types';
const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
// Each Miniflare instance holds a dedicated loopback port, so we can't keep
// every deployed worker resident indefinitely. Dispose a worker after this
// much inactivity; the next request lazily re-deploys it via cfCallLocal.
const WORKER_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const IDLE_SWEEP_INTERVAL_MS = 60 * 1000; // sweep cadence
const activeWorkers = new Map<string, Miniflare>();
// workerName -> last dispatch/deploy time (ms). Drives the idle sweep.
const lastAccess = new Map<string, number>();
let idleSweepTimer: ReturnType<typeof setInterval> | null = null;
export class LocalWorkerService extends PuterService {
declare protected stores: LayerInstances<typeof puterStores>;
declare protected services: LayerInstances<typeof puterServices>;
async cfDeployLocal(
workerName: string,
authorization: string,
code: string,
) {
await this.#disposeWorker(workerName);
try {
const mf = new Miniflare({
modules: false,
name: workerName,
bindings: {
puter_auth: authorization,
puter_endpoint: this.config.api_base_url,
}, // Binds variable/secret to environment
script: code,
} as WorkerOptions);
activeWorkers.set(workerName, mf);
this.#touch(workerName);
return { success: true, errors: [], url: null };
} catch (_e) {
return { success: false, errors: [], url: null };
}
}
async cfCallLocal(workerName: string, request: Request) {
let mf = activeWorkers.get(workerName);
if (!mf) {
// cfDeployLocal here
const existingSub: SubdomainRow | null =
await this.stores.subdomain.getBySubdomain(
'workers.puter.' + workerName,
);
if (!existingSub) {
return new Response('subdomain not found', { status: 404 });
}
const [_, authorization, code] = await this.reconstructDeployArgs(
workerName,
existingSub,
);
await this.cfDeployLocal(workerName, authorization, code);
mf = activeWorkers.get(workerName)!;
}
// Mark activity so the idle sweep keeps this worker resident.
this.#touch(workerName);
// `request` is a WHATWG Request built by the local-worker proxy
// middleware. Miniflare's `dispatchFetch(input, init)` needs us to coerce this
const hasBody = request.body != null;
return mf.dispatchFetch(request.url, {
method: request.method,
headers: [...request.headers] as [string, string][],
body: hasBody ? (request.body as unknown as BodyInit) : undefined,
// `duplex: 'half'` is required by undici when body is a stream.
...(hasBody ? { duplex: 'half' } : {}),
} as unknown as MiniflareRequestInit);
}
async cfDeleteLocal(workerName: string) {
await this.#disposeWorker(workerName);
return {};
}
// -- Idle lifecycle stuff
#touch(workerName: string): void {
lastAccess.set(workerName, Date.now());
this.#ensureIdleSweep();
}
async #disposeWorker(workerName: string): Promise<void> {
const mf = activeWorkers.get(workerName);
activeWorkers.delete(workerName);
lastAccess.delete(workerName);
if (mf) {
try {
await mf.dispose(); // releases the instance's port
} catch {
/* best-effort teardown */
}
}
}
// Lazily started on first deploy; disposes workers idle past the timeout
// and stops itself once nothing is resident.
#ensureIdleSweep(): void {
if (idleSweepTimer) return;
idleSweepTimer = setInterval(() => {
const now = Date.now();
for (const [name, ts] of [...lastAccess]) {
if (now - ts > WORKER_IDLE_TIMEOUT_MS) {
void this.#disposeWorker(name);
}
}
if (activeWorkers.size === 0 && idleSweepTimer) {
clearInterval(idleSweepTimer);
idleSweepTimer = null;
}
}, IDLE_SWEEP_INTERVAL_MS);
// Don't keep the process (or test runner) alive just for the sweep.
idleSweepTimer.unref?.();
}
override onServerShutdown(): void {
if (idleSweepTimer) {
clearInterval(idleSweepTimer);
idleSweepTimer = null;
}
for (const name of [...activeWorkers.keys()]) {
void this.#disposeWorker(name);
}
}
async reconstructDeployArgs(workerName: string, row: SubdomainRow) {
const appOwnerId = row.app_owner as number | null;
let authorization: string;
const ownerUser = await this.stores.user.getById(row.user_id);
if (!ownerUser) throw new Error('Owner seems to not exist');
const ownerActor = { user: ownerUser } as Actor;
if (appOwnerId) {
const app = await this.stores.app.getById(appOwnerId);
if (!app)
throw new Error(
'Local: Worker belongs to existant application',
); // app gone
authorization = await this.services.auth.createWorkerAppToken(
ownerActor,
app.uid,
workerName,
);
} else {
const session = await this.services.auth.createWorkerSessionToken(
ownerUser,
workerName,
);
authorization = session.token;
}
if (row.root_dir_id == null) {
throw new Error(
`Local: worker ${workerName} has no root_dir_id (source file)`,
);
}
const sourceEntry = await this.stores.fsEntry.getEntryById(
row.root_dir_id,
);
if (!sourceEntry) {
throw new Error(
`Local: worker ${workerName} source file not found (id=${row.root_dir_id})`,
);
}
const loaded = await loadFileInput(
{
fsEntry: this.stores.fsEntry,
s3Object: this.stores.s3Object,
},
this.services.fs,
ownerActor,
sourceEntry.path ?? sourceEntry.uuid,
{ maxBytes: MAX_SOURCE_SIZE },
);
const sourceCode = loaded.buffer.toString('utf-8');
const code = getWorkerPreamble() + sourceCode;
return [workerName, authorization, code];
}
}
@@ -21,6 +21,7 @@ import type { Actor } from '../../core/actor';
import { actorUid, isSystemActor, userRelatedActor } from '../../core/actor';
import { Context } from '../../core/context';
import { HttpError } from '../../core/http/HttpError.js';
import { Span } from '../../util/span.js';
import { PuterService } from '../types';
import {
FLAT_PERM_WARM_TTL_SECONDS,
@@ -197,6 +198,9 @@ export class PermissionService extends PuterService {
* some scanners are allowed to stop once one option is proven. Misses use
* the single-permission `check()` path to preserve exact semantics.
*/
@Span('permission.checkMany', (_actor: unknown, permissions: string[]) => ({
'permission.count': permissions?.length ?? 0,
}))
async checkMany(
actor: Actor,
permissions: string[],
@@ -281,6 +285,16 @@ export class PermissionService extends PuterService {
* options. Returns a tree-shaped "reading". Use
* `PermissionUtil.readingToOptions()` to flatten to a yes/no answer.
*/
// `check()` is a thin wrapper over scan(), so the span lives here —
// one span per evaluation regardless of which entry point was used.
@Span(
'permission.scan',
(_actor: unknown, permissionOptions: string | string[]) => ({
'permission.options': Array.isArray(permissionOptions)
? permissionOptions.join(',')
: permissionOptions,
}),
)
async scan(
actor: Actor,
permissionOptions: string | string[],
@@ -0,0 +1,43 @@
import bcrypt from 'bcrypt';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PuterServer } from '../../server.ts';
import { setupTestServer } from '../../testUtil.ts';
let server: PuterServer;
beforeAll(async () => {
server = await setupTestServer({
no_default_user: false,
} as never);
});
afterAll(async () => {
await server.shutdown();
});
describe('DefaultUserService — bootstrap admin credentials', () => {
it('stashes the bootstrap password so rotation can be detected', async () => {
const admin = await server.stores.user.getByUsername('admin');
expect(admin).toBeTruthy();
const stashed = admin?.metadata?.tmp_password;
expect(typeof stashed).toBe('string');
expect(
await bcrypt.compare(String(stashed), String(admin?.password)),
).toBe(true);
});
it('drops the plaintext stash once the password is rotated', async () => {
const admin = await server.stores.user.getByUsername('admin');
expect(admin).toBeTruthy();
await server.stores.user.update(admin!.id, {
password: await bcrypt.hash('rotated-password', 8),
});
// Simulate the next boot.
await server.services.defaultUser.onServerStart();
const fresh = await server.stores.user.getByUsername('admin');
expect(fresh?.metadata?.tmp_password ?? null).toBeNull();
});
});
@@ -41,7 +41,8 @@ const ADMIN_STORAGE_BYTES = 10 * 1024 * 1024 * 1024;
*
* Each boot where the current password hash still matches the stashed
* plaintext, the credentials are re-printed to stdout (CI scrapes this
* line to extract the default password).
* line to extract the default password). Once rotation is detected the
* stash is deleted so the plaintext isn't retained longer than needed.
*/
export class DefaultUserService extends PuterService {
override async onServerStart(): Promise<void> {
@@ -88,7 +89,13 @@ export class DefaultUserService extends PuterService {
tmpPassword,
String(user.password),
);
if (!isDefault) return;
if (!isDefault) {
// Password was rotated — stop retaining the plaintext stash.
await this.stores.user.updateMetadata(user.id, {
tmp_password: null,
});
return;
}
this.#printCredentials(tmpPassword);
}
+15
View File
@@ -44,6 +44,7 @@ import type {
SignedUploadPart,
SignedUploadResult,
} from './s3Types.js';
import { Span } from '../../util/span.js';
import { PuterStore } from '../types.js';
/**
@@ -108,6 +109,11 @@ export class S3ObjectStore extends PuterStore {
return result;
}
// Presigning is local crypto — no network call, so no aws-sdk auto
// span. These s3.* spans are the only visibility into it.
@Span('s3.batchCreateSignedUploadUrls', (files: unknown[]) => ({
'db.batch_size': files.length,
}))
async batchCreateSignedUploadUrls(
filesMetadata: SignedUploadInput[],
region: string,
@@ -251,6 +257,7 @@ export class S3ObjectStore extends PuterStore {
});
}
@Span('s3.createSignedMultipartPartUrls')
async createSignedMultipartPartUrls(
input: SignedMultipartPartUrlsInput,
region: string,
@@ -280,6 +287,7 @@ export class S3ObjectStore extends PuterStore {
);
}
@Span('s3.completeMultipartUpload')
async completeMultipartUpload(
input: MultipartCompleteInput,
region: string,
@@ -305,6 +313,7 @@ export class S3ObjectStore extends PuterStore {
);
}
@Span('s3.abortMutipartUpload')
async abortMutipartUpload(
uploadId: string,
region: string,
@@ -321,6 +330,7 @@ export class S3ObjectStore extends PuterStore {
);
}
@Span('s3.uploadFromServer')
async uploadFromServer(
input: ServerUploadInput,
region: string,
@@ -369,6 +379,7 @@ export class S3ObjectStore extends PuterStore {
* authoritative size of a direct-to-S3 (signed-URL) upload against the
* client-declared size, which is otherwise untrusted.
*/
@Span('s3.headObjectSize')
async headObjectSize(
bucket: string,
objectKey: string,
@@ -386,6 +397,7 @@ export class S3ObjectStore extends PuterStore {
: null;
}
@Span('s3.deleteObject')
async deleteObject(
bucket: string,
objectKey: string,
@@ -401,6 +413,7 @@ export class S3ObjectStore extends PuterStore {
}
// Batch delete up to 1000 objects per S3 API limit; callers chunk if needed.
@Span('s3.deleteObjects')
async deleteObjects(
input: DeleteObjectsInput,
region: string,
@@ -427,6 +440,7 @@ export class S3ObjectStore extends PuterStore {
}
// Server-side copy. Avoids downloading/re-uploading bytes.
@Span('s3.copyObject')
async copyObject(input: CopyObjectInput, region: string): Promise<void> {
const client = this.#getClientForRegion(region);
await client.send(
@@ -444,6 +458,7 @@ export class S3ObjectStore extends PuterStore {
);
}
@Span('s3.getObjectStream')
async getObjectStream(
input: GetObjectInput,
region: string,
@@ -20,6 +20,29 @@
import { v4 as uuidv4 } from 'uuid';
import { PuterStore } from '../types';
/**
* A row from the `subdomains` table (the shape `getBySubdomain` / `getByUuid`
* resolve to). Kept alongside the store so callers share one definition instead
* of redeclaring it locally.
*/
export interface SubdomainRow {
id: number;
uuid: string;
ts: number | string; // system timestamp
subdomain: string; // immutable name
user_id: number; // owner
app_owner: number | null; // owning app, if any
protected: 0 | 1; // access gate
database_id: string | null; // Cloudflare D1 binding
root_dir_id: number | null; // editable
associated_app_id: string | null; // editable
domain: string | null; // custom domain, editable
// `SELECT *` may surface columns not modelled above (and callers still
// treat rows as `Record<string, unknown>` in places). The index signature
// keeps the named fields strongly typed while staying Record-compatible.
[key: string]: unknown;
}
// Columns that may not be set through an `update` patch map. Defence-in-depth
// against future callers (admin routes, extensions, new REST handlers) that
// might forward `req.body` straight into the store: the driver's update
@@ -62,7 +85,16 @@ const NEGATIVE_CACHE_TTL_SECONDS = 10;
export class SubdomainStore extends PuterStore {
// -- Reads --------------------------------------------------------
async getByUuid(uuid, { userId, primary = false } = {}) {
async getByUuid(
uuid: string,
{
userId,
primary = false,
}: {
userId?: number | undefined;
primary?: boolean;
} = {},
): Promise<SubdomainRow | null> {
const where =
userId !== undefined
? 'WHERE `uuid` = ? AND `user_id` = ?'
@@ -72,29 +104,41 @@ export class SubdomainStore extends PuterStore {
const rows = primary
? await this.clients.db.pread(sql, params)
: await this.clients.db.read(sql, params);
return rows[0] ?? null;
return (rows[0] as unknown as SubdomainRow) ?? null;
}
async getBySubdomain(subdomain) {
/**
* Pass `primary: true` for read-after-write lookups (e.g. checking a
* subdomain that may have been created moments ago): it skips the
* cache — which may hold a stale negative marker — and reads the
* primary instead of a possibly-lagging replica. The result still
* refreshes the cache, healing any stale marker.
*/
async getBySubdomain(
subdomain: string,
{ primary = false }: { primary?: boolean } = {},
): Promise<SubdomainRow | null> {
if (!subdomain) return null;
const cacheKey = this.#cacheKey(subdomain);
try {
const raw = await this.clients.redis.get(cacheKey);
if (raw === NEGATIVE_CACHE_MARKER) return null;
if (raw) {
const parsed = JSON.parse(raw);
if (parsed) return parsed;
if (!primary) {
try {
const raw = await this.clients.redis.get(cacheKey);
if (raw === NEGATIVE_CACHE_MARKER) return null;
if (raw) {
const parsed = JSON.parse(raw) as SubdomainRow | null;
if (parsed) return parsed;
}
} catch {
/* fall through */
}
} catch {
/* fall through */
}
const rows = await this.clients.db.read(
'SELECT * FROM `subdomains` WHERE `subdomain` = ? LIMIT 1',
[subdomain],
);
const row = rows[0] ?? null;
const sql = 'SELECT * FROM `subdomains` WHERE `subdomain` = ? LIMIT 1';
const rows = primary
? await this.clients.db.pread(sql, [subdomain])
: await this.clients.db.read(sql, [subdomain]);
const row = (rows[0] as unknown as SubdomainRow | undefined) ?? null;
if (row) {
this.clients.redis
@@ -113,7 +157,7 @@ export class SubdomainStore extends PuterStore {
return row;
}
async listByUserId(userId, { limit = 500 } = {}) {
async listByUserId(userId: number, { limit = 500 } = {}) {
const rows = await this.clients.db.read(
`SELECT * FROM \`subdomains\` WHERE \`user_id\` = ? LIMIT ?`,
[userId, limit],
@@ -129,7 +173,7 @@ export class SubdomainStore extends PuterStore {
return rows;
}
async existsBySubdomain(subdomain) {
async existsBySubdomain(subdomain: string) {
// Reuse the positive/negative cache populated by getBySubdomain —
// creation uniqueness checks and the Workers quota path would
// otherwise punch through to the DB on every call.
@@ -137,7 +181,7 @@ export class SubdomainStore extends PuterStore {
return row != null;
}
async countByUserId(userId) {
async countByUserId(userId: number) {
const rows = await this.clients.db.read(
'SELECT COUNT(*) AS n FROM `subdomains` WHERE `user_id` = ?',
[userId],
@@ -145,7 +189,7 @@ export class SubdomainStore extends PuterStore {
return rows[0]?.n ?? 0;
}
async getByDomain(domain) {
async getByDomain(domain: string) {
const rows = await this.clients.db.read(
'SELECT * FROM `subdomains` WHERE `domain` = ? LIMIT 1',
[domain],
@@ -153,14 +197,18 @@ export class SubdomainStore extends PuterStore {
return rows[0] ?? null;
}
async listByDomain(domain) {
async listByDomain(domain: string) {
return this.clients.db.read(
'SELECT * FROM `subdomains` WHERE `domain` = ?',
[domain],
);
}
async listByUserIdAndPrefix(userId, prefix, extra = {}) {
async listByUserIdAndPrefix(
userId: number,
prefix: string,
extra: { appId?: number } = {},
): Promise<SubdomainRow[]> {
if (!userId || prefix == null) return [];
const like = `${prefix}%`;
@@ -177,7 +225,7 @@ export class SubdomainStore extends PuterStore {
);
}
return rows;
return rows as unknown as SubdomainRow[];
}
// -- Writes -------------------------------------------------------
@@ -190,6 +238,13 @@ export class SubdomainStore extends PuterStore {
associatedAppId = null,
appOwner = null,
preambleVersion = null,
}: {
userId: number;
subdomain: string;
rootDirId?: number | null;
associatedAppId?: number | null;
appOwner?: number | null;
preambleVersion?: string | null;
}) {
if (!userId || !subdomain) {
throw new Error('create: userId and subdomain are required');
@@ -226,8 +281,16 @@ export class SubdomainStore extends PuterStore {
return row;
}
async update(uuid, patch, { userId } = {}) {
const allowed = {};
async update(
uuid: string,
patch: Record<string, unknown>,
{
userId,
}: {
userId?: number | undefined;
} = {},
) {
const allowed: Record<string, unknown> = {};
for (const [k, v] of Object.entries(patch)) {
if (READ_ONLY_COLUMNS.has(k)) continue;
allowed[k] = v;
@@ -284,7 +347,14 @@ export class SubdomainStore extends PuterStore {
return after;
}
async deleteByUuid(uuid, { userId } = {}) {
async deleteByUuid(
uuid: string,
{
userId,
}: {
userId?: number | undefined;
} = {},
) {
const row = await this.getByUuid(uuid, { userId });
const where =
@@ -313,15 +383,15 @@ export class SubdomainStore extends PuterStore {
// -- Internals ----------------------------------------------------
#cacheKey(subdomain) {
#cacheKey(subdomain: string) {
return `${CACHE_KEY_PREFIX}:name:${subdomain}`;
}
#prefixListTrackerKey(userId) {
#prefixListTrackerKey(userId: number) {
return `${CACHE_KEY_PREFIX}:listByUserPrefixKeys:${userId}`;
}
async #refreshCache(row) {
async #refreshCache(row: { subdomain?: string }) {
if (!row?.subdomain) return;
await this.publishCacheKeys({
keys: [this.#cacheKey(row.subdomain)],
@@ -336,7 +406,7 @@ export class SubdomainStore extends PuterStore {
// renamed subdomain keeps showing up under its old folder in the GUI
// (website badge, "associated websites" popover) until the entry's
// independent TTL expires.
async #invalidateRootDirEntry(rootDirId) {
async #invalidateRootDirEntry(rootDirId: number | null | undefined) {
if (rootDirId == null) return;
const id =
typeof rootDirId === 'number' ? rootDirId : Number(rootDirId);
@@ -350,7 +420,7 @@ export class SubdomainStore extends PuterStore {
}
}
async #invalidatePrefixListsForUser(userId) {
async #invalidatePrefixListsForUser(userId: number) {
if (userId == null) return;
const trackerKey = this.#prefixListTrackerKey(userId);
let cacheKeys = [];
+8
View File
@@ -452,6 +452,14 @@ interface IConfigOptional {
env: 'dev' | 'prod';
/** Free-form name of the config profile (e.g. `oss-default`). Surfaced in logs. */
config_name: string;
/**
* Console output format. `json` replaces the global console so every call
* emits one structured JSON line (`level`, `timestamp`, `msg`, and the
* active `traceId`) — one event per call, so a line-oriented log collector
* can't split stack traces across events, and level filtering works. `text`
* (the default) leaves console output human-readable for local/dev.
*/
log_format: 'json' | 'text';
/** Server version. Falls back to `npm_package_version`. */
version: string;
/** Stable identity for this server node. Enables pager alerts + graceful shutdown delay. */
+164
View File
@@ -0,0 +1,164 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { installJsonConsole } from './jsonConsole.js';
/**
* Capture what the patched console writes to stdout/stderr. Returns the raw
* chunks plus helpers to parse them, then uninstall() restores everything.
*/
const withInstalledConsole = (
options?: Parameters<typeof installJsonConsole>[0],
) => {
const out: string[] = [];
const err: string[] = [];
const stdout = vi
.spyOn(process.stdout, 'write')
.mockImplementation((chunk: unknown) => {
out.push(String(chunk));
return true;
});
const stderr = vi
.spyOn(process.stderr, 'write')
.mockImplementation((chunk: unknown) => {
err.push(String(chunk));
return true;
});
const uninstall = installJsonConsole(options);
return {
out,
err,
uninstall: () => {
uninstall();
stdout.mockRestore();
stderr.mockRestore();
},
};
};
describe('installJsonConsole', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('emits one JSON line per call with level, timestamp and msg', () => {
const { out, uninstall } = withInstalledConsole();
try {
console.log('hello world');
} finally {
uninstall();
}
expect(out).toHaveLength(1);
expect(out[0].endsWith('\n')).toBe(true);
const entry = JSON.parse(out[0]);
expect(entry.level).toBe('info');
expect(entry.msg).toBe('hello world');
expect(() => new Date(entry.timestamp).toISOString()).not.toThrow();
expect(entry.timestamp).toBe(new Date(entry.timestamp).toISOString());
});
it('maps each console method to the expected level and stream', () => {
const { out, err, uninstall } = withInstalledConsole();
try {
console.info('i');
console.debug('d');
console.warn('w');
console.error('e');
} finally {
uninstall();
}
expect(out.map((l) => JSON.parse(l).level)).toEqual(['info', 'debug']);
expect(err.map((l) => JSON.parse(l).level)).toEqual(['warn', 'error']);
});
it('formats non-string args like console does (objects preserved)', () => {
const { out, uninstall } = withInstalledConsole();
try {
console.log('user', { id: 5, roles: ['a'] }, [1, 2]);
} finally {
uninstall();
}
const entry = JSON.parse(out[0]);
expect(entry.msg).toBe("user { id: 5, roles: [ 'a' ] } [ 1, 2 ]");
});
it('collapses a multi-line stack trace into a single log event', () => {
const { err, uninstall } = withInstalledConsole();
try {
console.error(new Error('boom'));
} finally {
uninstall();
}
// Exactly one write, one trailing newline, no interior raw newlines
// (the stack lives inside the JSON-escaped `msg` string).
expect(err).toHaveLength(1);
expect(err[0].match(/\n/g)).toHaveLength(1);
const entry = JSON.parse(err[0]);
expect(entry.level).toBe('error');
expect(entry.msg).toContain('Error: boom');
expect(entry.msg).toContain('\n at '); // stack frames survive in msg
});
it('attaches traceId/spanId only when a span is active', () => {
let ctx: { traceId: string; spanId?: string } | undefined;
const { out, uninstall } = withInstalledConsole({
getTraceContext: () => ctx,
});
try {
console.log('no span');
ctx = { traceId: 'abc123', spanId: 'def456' };
console.log('with span');
} finally {
uninstall();
}
const first = JSON.parse(out[0]);
expect(first).not.toHaveProperty('traceId');
const second = JSON.parse(out[1]);
expect(second.traceId).toBe('abc123');
expect(second.spanId).toBe('def456');
});
it('restores the original console methods on uninstall', () => {
const before = console.log;
const { uninstall } = withInstalledConsole();
expect(console.log).not.toBe(before);
uninstall();
expect(console.log).toBe(before);
});
it('is idempotent — a second install is a no-op', () => {
const { out, uninstall } = withInstalledConsole();
const second = installJsonConsole();
try {
console.log('once');
} finally {
second();
uninstall();
}
// Still a single JSON line, not double-patched.
expect(out).toHaveLength(1);
expect(JSON.parse(out[0]).msg).toBe('once');
});
});
+116
View File
@@ -0,0 +1,116 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { format } from 'node:util';
/**
* Console severity methods we replace, mapped to the `level` value emitted for
* each. `log` collapses to `info` so downstream `level = "info"` / `"error"`
* filters behave conventionally.
*/
const METHOD_LEVELS = {
log: 'info',
info: 'info',
warn: 'warn',
error: 'error',
debug: 'debug',
} as const;
type ConsoleMethod = keyof typeof METHOD_LEVELS;
/** Identifiers for the currently-active trace, if any. */
export interface TraceContext {
traceId: string;
spanId?: string;
}
export interface JsonConsoleOptions {
/**
* Resolves the active trace context at log time, or `undefined` when no
* recording span is active. Kept as a callback so this module carries no
* telemetry dependency and stays trivially unit-testable.
*/
getTraceContext?: () => TraceContext | undefined;
}
// Guard against double-installation across duplicate module instances.
const INSTALLED_FLAG = '__puterJsonConsoleInstalled';
/**
* Replace the global console severity methods so every call emits exactly one
* line of JSON: `{ level, timestamp, msg, traceId?, spanId? }`. `msg` is
* `util.format`ed from the call args — byte-for-byte what console would have
* printed — so multi-line values (stack traces, inspected objects) become a
* single log event instead of being split across many by a line-oriented log
* collector.
*
* Returns a function that restores the original console methods.
*/
export const installJsonConsole = (
options: JsonConsoleOptions = {},
): (() => void) => {
const globals = globalThis as Record<string, unknown>;
if (globals[INSTALLED_FLAG]) return () => {};
const { getTraceContext } = options;
const originals = {} as Record<ConsoleMethod, (...args: unknown[]) => void>;
for (const method of Object.keys(METHOD_LEVELS) as ConsoleMethod[]) {
// Keep the exact reference so uninstall() restores it identically.
const original = console[method] as (...args: unknown[]) => void;
originals[method] = original;
const level = METHOD_LEVELS[method];
// Match console's stream routing so stderr keeps carrying warnings and
// errors even in JSON mode.
const stream =
method === 'warn' || method === 'error'
? process.stderr
: process.stdout;
console[method] = (...args: unknown[]): void => {
try {
const entry: Record<string, unknown> = {
level,
timestamp: new Date().toISOString(),
msg: format(...args),
};
const trace = getTraceContext?.();
if (trace?.traceId) {
entry.traceId = trace.traceId;
if (trace.spanId) entry.spanId = trace.spanId;
}
stream.write(`${JSON.stringify(entry)}\n`);
} catch {
// Logging must never take down the process — fall back to the
// untouched console method if formatting/serialization throws.
original.apply(console, args);
}
};
}
globals[INSTALLED_FLAG] = true;
return () => {
for (const method of Object.keys(originals) as ConsoleMethod[]) {
console[method] = originals[method];
}
delete globals[INSTALLED_FLAG];
};
};
+120
View File
@@ -0,0 +1,120 @@
import { SpanStatusCode, trace } from '@opentelemetry/api';
import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-base';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { Span, withSpan } from './span';
const exporter = new InMemorySpanExporter();
const provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
trace.setGlobalTracerProvider(provider);
afterAll(async () => {
await provider.shutdown();
trace.disable();
});
beforeEach(() => {
exporter.reset();
});
describe('withSpan', () => {
it('records a span around a sync function and returns its value', () => {
const result = withSpan('sync.op', { a: 1 }, () => 42);
expect(result).toBe(42);
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0].name).toBe('sync.op');
expect(spans[0].attributes).toMatchObject({ a: 1 });
expect(spans[0].status.code).toBe(SpanStatusCode.OK);
});
it('records a span around an async function', async () => {
const result = await withSpan('async.op', {}, async () => 'ok');
expect(result).toBe('ok');
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0].name).toBe('async.op');
expect(spans[0].status.code).toBe(SpanStatusCode.OK);
});
it('marks the span errored and rethrows on async rejection', async () => {
await expect(
withSpan('async.fail', {}, async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0].status.code).toBe(SpanStatusCode.ERROR);
expect(spans[0].events.some((e) => e.name === 'exception')).toBe(true);
});
it('resolves an attribute factory lazily', () => {
withSpan('factory.op', () => ({ computed: 'yes' }), () => null);
expect(exporter.getFinishedSpans()[0].attributes).toMatchObject({
computed: 'yes',
});
});
});
describe('Span decorator', () => {
it('defaults the span name to Class.method', async () => {
class Widget {
@Span()
async fetch() {
return 'data';
}
}
expect(await new Widget().fetch()).toBe('data');
expect(exporter.getFinishedSpans()[0].name).toBe('Widget.fetch');
});
it('uses an explicit name and static attributes', async () => {
class Widget {
@Span('custom.name', { kind: 'static' })
async fetch() {
return 1;
}
}
await new Widget().fetch();
const [span] = exporter.getFinishedSpans();
expect(span.name).toBe('custom.name');
expect(span.attributes).toMatchObject({ kind: 'static' });
});
it('builds attributes from the call arguments via a factory', async () => {
class Db {
@Span('db.read', (query: string) => ({ 'db.statement': query }))
async read(query: string, _params: unknown[] = []) {
return [query];
}
}
await new Db().read('SELECT 1', []);
const [span] = exporter.getFinishedSpans();
expect(span.name).toBe('db.read');
expect(span.attributes).toMatchObject({ 'db.statement': 'SELECT 1' });
});
it('records errors thrown by the decorated method', async () => {
class Db {
@Span('db.read')
async read() {
throw new Error('nope');
}
}
await expect(new Db().read()).rejects.toThrow('nope');
expect(exporter.getFinishedSpans()[0].status.code).toBe(
SpanStatusCode.ERROR,
);
});
});
+17 -2
View File
@@ -34,6 +34,9 @@ export function withSpan<T>(
): T {
return tracer.startActiveSpan(name, (span) => {
try {
// Mirror the name into an attribute: X-Ray can only filter by
// annotations, and annotations come from attributes.
span.setAttribute('span_name', name);
const a = typeof attrs === 'function' ? attrs() : attrs;
if (a) span.setAttributes(a);
const result = fn();
@@ -77,9 +80,15 @@ function recordError(span: ReturnType<typeof tracer.startSpan>, err: unknown) {
*
* @Span('custom.name') // span name = "custom.name"
* async baz() { ... }
*
* @Span('db.read', (query: string) => ({ 'db.statement': query }))
* async read(query: string) { ... } // attrs built from call args
* }
*/
export function Span(name?: string) {
export function Span(
name?: string,
attrs?: Attributes | ((...args: never[]) => Attributes),
) {
return function <This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Return,
ctx: ClassMethodDecoratorContext<
@@ -91,7 +100,13 @@ export function Span(name?: string) {
const spanName =
name ??
`${(this as { constructor?: { name?: string } })?.constructor?.name ?? 'fn'}.${String(ctx.name)}`;
return withSpan(spanName, {}, () => target.apply(this, args));
const spanAttrs: AttrsOrFactory =
typeof attrs === 'function'
? () => attrs(...(args as unknown as never[]))
: (attrs ?? {});
return withSpan(spanName, spanAttrs, () =>
target.apply(this, args),
);
};
};
}
+5 -3
View File
@@ -45,9 +45,11 @@ if ( window.url_params.has('puter.domain') ) {
// static hosting domain
window.static_hosting_domain = 'puter.site';
if ( window.domain === 'puter.localhost' ) {
window.static_hosting_domain = 'site.puter.localhost';
}
if ( window.domain === 'puter.localhost' ) {
window.static_hosting_domain = 'site.puter.localhost';
} else if ( window.domain && window.domain !== 'puter.com' ) {
window.static_hosting_domain = `site.${window.domain}`;
}
// add port to static_hosting_domain if provided
if ( window.url_params.has('puter.port') && window.url_params.get('puter.port') ) {
+23 -59
View File
@@ -3,68 +3,32 @@ title: User-Pays Model
description: Discover Puter.js User-Pays Model and how it allows you to build applications without worrying about infrastructure costs.
---
The User-Pays Model means your users cover their own cloud and AI usage. Instead of you, as a developer, paying for servers and APIs, users bring and pay for their own AI, storage and other features you've built into your application, making your app practically free to run!
The User-Pays Model means your users cover their own cloud and AI usage, instead of you, the developer. Each user pays for the resources they consume in your app through their own Puter account. Whether you have 1 or 1 million users, your infrastructure cost stays at $0.
When users interact with your Puter.js-powered apps, they handle their own resource consumption. This means you can include powerful features without worrying about the costs; whether you have 1 or 1 million users, you pay nothing for the infrastructure to run your application.
## How it works
- Users sign in to your app with their Puter account, and that account covers the AI, storage, and other resources they use. Their usage never touches your bill.
- Every user starts with a free monthly allowance, enough to test out the platform and use apps built with Puter.js. They can track their usage in the [usage dashboard](https://puter.com/dashboard#usage).
- If a user runs out of their allowance, Puter prompts them to upgrade for more, or they can do it themselves in their [billing settings](https://puter.com/dashboard#billing).
<div class="info">When you, as a developer, use your own app, you are also subject to the User-Pays Model: you cover your own usage like any other user.</div>
## User-Pays Model vs. Traditional Model
With traditional model of building applications, you have to set up servers, databases, and cloud services before you even launch. If you use cloud services like AWS or Google Cloud, you have to configure those, manage API keys, and set up billing.
| | Traditional | Puter.js |
| --- | --- | --- |
| **Servers & databases** | You set up and pay | None needed |
| **API keys** | You manage and secure them | No API keys at all |
| **Billing** | You pay for all users' usage | Each user pays their own |
| **Scaling** | Costs grow with users | Zero cost at any scale |
| **Abuse protection** | Rate limits, CAPTCHAs, quotas | Not needed; abusers pay for themselves |
Puter.js with user-pays model solves this, so you can build applications without worrying about server bills, scaling costs, or usage spikes.
## Advantages
## Advantages of the User-Pays Model
**1. Zero Server, AI, and APIs Costs**
The most significant advantage is that, as a developer, you don't pay any infrastructure costs when using Puter.js. Whether your app serves one user or one million users, your costs remain the same: zero. Practically infinite scalability at no cost.
**2. No API Keys Needed**
The User-Pays Model makes true serverless architecture a reality. You don't need to:
- Manage various AI and cloud services
- Worry about securing your API keys usage
- Ask users to bring their own API keys
**3. Built-in Security**
The authentication and authorization are handled by Puter's infrastructure:
- Users authenticate directly with Puter
- Your app operates within the permissions granted by the user
- Data is protected through Puter's security mechanisms
**4. No Anti-Abuse Implementation Required**
You don't need to implement:
- Rate limiting
- CAPTCHA verification
- IP blocking
- Usage quotas
- Fraud detection
Bad actors have no incentive to abuse the system because they are paying for their own usage.
**5. Simpler Codebase**
Since cloud, AI, and other APIs are all handled through Puter.js:
- Your codebase is significantly simpler
- You can focus entirely on your application's unique functionality
- Frontend-only development is possible for many applications
**6. Simplified User Experience**
For your users:
- Single sign-on through Puter
- Unified billing through their existing Puter account
- No need to create accounts with multiple service providers
<br>
## Everybody wins!
The User-Pays Model enables you to build advanced applications with AI, cloud storage, and auth, all from the frontend, without worrying about infrastructure, security, or scaling. It's a win-win situation where developers can ship without the cloud services costs, and users only covering for what they use.
- **Zero infrastructure costs**: no server, AI, or API bills, at any scale.
- **Perfect for vibe coding**: build and ship apps with AI without fear of a surprise bill. If your app goes viral overnight, each user still covers their own usage.
- **No API keys**: no keys to buy, secure, or ask your users to bring.
- **Built-in auth & security**: users sign in with Puter; your app runs within the permissions they grant.
- **No anti-abuse code**: bad actors pay for their own usage, so there's no incentive to abuse your app.
- **Simpler codebase**: cloud and AI are handled by Puter.js, so many apps can be frontend-only.
- **Better UX**: single sign-on and unified billing through the user's existing Puter account.
+288 -85
View File
@@ -32,47 +32,82 @@ const APP_NAMES_NO_UNINSTALL = new Set([
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
function buildAppsGrid (apps) {
if ( !apps || apps.length === 0 ) {
let h = '<div class="myapps-empty">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">';
h += '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/>';
h += '<rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>';
h += '</svg>';
h += '<p>No apps installed yet</p>';
h += '</div>';
return h;
// External apps (not owned by a Puter user) can report an opaque app-… id
// as their title (uid === name === title); in that case show the hostname
// of index_url instead, and open the app's website (index_url) on click —
// matching the Home tab.
function resolveTileDisplay (app) {
let title = (app.title || app.name || '').trim();
let targetLink = '';
const appUid = app.uid || app.uuid;
if (
app.external &&
app.name === app.title &&
app.name === appUid &&
app.index_url
) {
title = new URL(app.index_url).hostname;
targetLink = app.index_url;
}
let h = '<div class="myapps-grid myapps-grid-loading">';
for ( const app of apps ) {
let title = (app.title || app.name || '').trim();
let targetLink = '';
return { title, targetLink };
}
// External apps (not owned by a Puter user) can report an opaque app-… id
// as their title (uid === name === title); in that case show the hostname
// of index_url instead, and open the app's website (index_url) on click —
// matching the Home tab.
const appUid = app.uid || app.uuid;
if (
app.external &&
app.name === app.title &&
app.name === appUid &&
app.index_url
) {
title = new URL(app.index_url).hostname;
targetLink = app.index_url;
function buildTileHtml (app) {
const { title, targetLink } = resolveTileDisplay(app);
const iconUrl = app.iconUrl || window.icons['app.svg'];
let h = `<div class="myapps-tile" data-app-name="${html_encode(app.name)}" data-app-title="${html_encode(title)}" data-app-uid="${html_encode(app.uid || '')}" data-target-link="${html_encode(targetLink)}" title="${html_encode(title)}">`;
h += '<div class="myapps-tile-icon">';
h += `<img src="${html_encode(iconUrl)}" alt="" draggable="false">`;
h += '</div>';
h += `<span class="myapps-tile-label">${html_encode(title)}</span>`;
h += '</div>';
return h;
}
function buildNoAppsHtml () {
let h = '<div class="myapps-empty">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">';
h += '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/>';
h += '<rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>';
h += '</svg>';
h += '<p>No apps installed yet</p>';
h += '</div>';
return h;
}
// iOS-home-screen-style pager: fixed cols × rows pages in a horizontal
// scroll-snap scroller, with page dots below and hover arrows for mouse users.
function buildPagerHtml (apps, layout) {
const pageCount = Math.ceil(apps.length / layout.perPage);
let h = `<div class="myapps-pager myapps-pager-loading" style="--myapps-cols: ${layout.cols}">`;
h += '<div class="myapps-pager-scroller">';
for ( let p = 0; p < pageCount; p++ ) {
h += '<div class="myapps-page">';
for ( const app of apps.slice(p * layout.perPage, (p + 1) * layout.perPage) ) {
h += buildTileHtml(app);
}
const iconUrl = app.iconUrl || window.icons['app.svg'];
h += `<div class="myapps-tile" data-app-name="${html_encode(app.name)}" data-app-title="${html_encode(title)}" data-app-uid="${html_encode(app.uid || '')}" data-target-link="${html_encode(targetLink)}" title="${html_encode(title)}">`;
h += '<div class="myapps-tile-icon">';
h += `<img src="${html_encode(iconUrl)}" alt="" draggable="false">`;
h += '</div>';
h += `<span class="myapps-tile-label">${html_encode(title)}</span>`;
h += '</div>';
}
h += '</div>';
h += '<button class="myapps-pager-arrow myapps-pager-arrow-prev" aria-label="Previous page">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>';
h += '</button>';
h += '<button class="myapps-pager-arrow myapps-pager-arrow-next" aria-label="Next page">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>';
h += '</button>';
h += `<div class="myapps-pager-dots${pageCount < 2 ? ' myapps-pager-dots-hidden' : ''}" aria-label="App pages">`;
for ( let p = 0; p < pageCount; p++ ) {
h += `<button class="myapps-pager-dot" data-page="${p}" aria-label="Page ${p + 1} of ${pageCount}"></button>`;
}
h += '</div>';
h += '</div>';
return h;
}
@@ -113,15 +148,8 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) {
try {
await puter.perms.revokeApp(appUid, '*');
// Remove from internal state
self._apps = self._apps.filter(a => a.name !== appName);
// Remove tile from DOM
$el_window.find(`.myapps-tile[data-app-name="${appName}"]`).remove();
// Show empty state if no apps left
if ( self._apps.length === 0 ) {
const $container = $el_window.find('.myapps-container');
$container.html(buildAppsGrid(null));
}
self.renderApps($el_window, { preservePage: true });
} catch ( err ) {
console.error('Failed to uninstall app:', err);
}
@@ -131,12 +159,14 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) {
}
function revealWhenLoaded ($container) {
const $grid = $container.find('.myapps-grid-loading');
if ( $grid.length === 0 ) return;
const $pager = $container.find('.myapps-pager-loading');
if ( $pager.length === 0 ) return;
const imgs = $grid.find('img').toArray();
// Only the first page's icons gate the fade-in; the other pages are
// offscreen and their icons can finish loading behind it.
const imgs = $pager.find('.myapps-page').first().find('img').toArray();
if ( imgs.length === 0 ) {
$grid.removeClass('myapps-grid-loading');
$pager.removeClass('myapps-pager-loading');
return;
}
@@ -146,7 +176,7 @@ function revealWhenLoaded ($container) {
function onDone () {
loaded++;
if ( loaded >= total ) {
$grid.removeClass('myapps-grid-loading');
$pager.removeClass('myapps-pager-loading');
}
}
@@ -166,6 +196,9 @@ const TabApps = {
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/></svg>',
_apps: null,
_layout: null,
_page: 0,
_pageCount: 0,
html () {
let h = '<div class="dashboard-tab-content myapps-tab">';
@@ -189,46 +222,16 @@ const TabApps = {
const self = this;
// Toggle search/clear icons and filter
function updateSearch ($input) {
const query = $input.val().toLowerCase().trim();
const hasText = query.length > 0;
$el_window.find('.myapps-icon-search').toggle(!hasText);
$el_window.find('.myapps-icon-clear').toggle(hasText);
if ( ! self._apps ) return;
const $container = $el_window.find('.myapps-container');
if ( ! query ) {
$container.html(buildAppsGrid(self._apps));
revealWhenLoaded($container);
return;
}
const filtered = self._apps.filter(app => {
const title = (app.title || '').toLowerCase();
const name = (app.name || '').toLowerCase();
return title.includes(query) || name.includes(query);
});
$container.html(
filtered.length > 0
? buildAppsGrid(filtered)
: '<div class="myapps-empty"><p>No apps match your search</p></div>',
);
revealWhenLoaded($container);
}
$el_window.on('input', '.myapps-search', function () {
updateSearch($(this));
self.updateSearchIcons($el_window);
self.renderApps($el_window);
});
// Clear search on cross click
$el_window.on('click', '.myapps-icon-clear', function () {
const $input = $el_window.find('.myapps-search');
$input.val('').focus();
updateSearch($input);
$el_window.find('.myapps-search').val('').focus();
self.updateSearchIcons($el_window);
self.renderApps($el_window);
});
// Handle app tile clicks. External apps carry a target link (their
@@ -282,6 +285,207 @@ const TabApps = {
items,
});
});
// -- Pager navigation --
$el_window.on('click', '.myapps-pager-dot', function () {
self.goToPage($el_window, parseInt($(this).attr('data-page'), 10), true);
});
$el_window.on('click', '.myapps-pager-arrow-prev', function () {
self.goToPage($el_window, self._page - 1, true);
});
$el_window.on('click', '.myapps-pager-arrow-next', function () {
self.goToPage($el_window, self._page + 1, true);
});
// Mouse wheel / two-finger vertical swipe flips one page per gesture.
// Horizontal trackpad panning is left to the native scroller, whose
// snap points already handle it.
$el_window.on('wheel', '.myapps-pager-scroller', function (e) {
const oe = e.originalEvent;
if ( Math.abs(oe.deltaX) > Math.abs(oe.deltaY) ) return;
if ( Math.abs(oe.deltaY) < 4 ) return;
e.preventDefault();
// Inertial scrolling keeps emitting events after the flip; treat
// everything within 150ms of the last event as the same gesture.
clearTimeout(self._wheelTimer);
self._wheelTimer = setTimeout(() => {
self._wheelActive = false;
}, 150);
if ( self._wheelActive ) return;
self._wheelActive = true;
self.goToPage($el_window, self._page + (oe.deltaY > 0 ? 1 : -1), true);
});
// Left/right arrow keys flip pages, including while the (empty) search
// box holds focus — it's auto-focused on desktop, Launchpad-style.
$(document).off('keydown.myapps-pager').on('keydown.myapps-pager', function (e) {
if ( e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' ) return;
if ( ! $el_window.find('.dashboard-section-apps').hasClass('active') ) return;
if ( ! $el_window.is(':visible') ) return;
if ( $el_window.find('.myapps-modal-overlay').length ) return;
if ( $('.window').not($el_window[0]).filter(':visible').length ) return;
const ae = document.activeElement;
if ( ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT' || ae.isContentEditable) ) {
if ( ! ($(ae).hasClass('myapps-search') && ae.value === '') ) return;
}
e.preventDefault();
self.goToPage($el_window, self._page + (e.key === 'ArrowRight' ? 1 : -1), true);
});
// Re-paginate when the container resizes (window resize, sidebar
// collapse, tab becoming visible, on-screen keyboard, …).
if ( self._resizeObserver ) self._resizeObserver.disconnect();
self._resizeObserver = new ResizeObserver(() => {
clearTimeout(self._resizeTimer);
self._resizeTimer = setTimeout(() => {
if ( ! self._apps ) return;
const layout = self.computeLayout($el_window.find('.myapps-container'));
if ( ! layout ) return;
if ( self._layout && layout.cols === self._layout.cols && layout.rows === self._layout.rows ) {
// Same grid, new page width — just re-align the scroller.
self.goToPage($el_window, self._page, false);
return;
}
self.renderApps($el_window, { preservePage: true });
}, 100);
});
self._resizeObserver.observe($el_window.find('.myapps-container')[0]);
},
updateSearchIcons ($el_window) {
const hasText = String($el_window.find('.myapps-search').val() || '').trim().length > 0;
$el_window.find('.myapps-icon-search').toggle(!hasText);
$el_window.find('.myapps-icon-clear').toggle(hasText);
},
computeLayout ($container) {
const el = $container[0];
if ( ! el || el.clientWidth === 0 || el.clientHeight === 0 ) return null;
const cs = getComputedStyle(el);
const readVar = (name, fallback) => {
const v = parseFloat(cs.getPropertyValue(name));
return Number.isFinite(v) ? v : fallback;
};
const tileW = readVar('--myapps-tile-w', 100);
const tileH = readVar('--myapps-tile-h', 78);
const gapX = readVar('--myapps-gap-x', 32);
const gapY = readVar('--myapps-gap-y', 32);
const dotsH = readVar('--myapps-dots-h', 28);
const width = el.clientWidth;
const height = Math.max(tileH, el.clientHeight - dotsH);
const cols = Math.max(1, Math.floor((width + gapX) / (tileW + gapX)));
const rows = Math.max(1, Math.floor((height + gapY) / (tileH + gapY)));
return { cols, rows, perPage: cols * rows };
},
// Central renderer: applies the current search query to _apps and rebuilds
// the pager (or an empty state). Everything that changes what's shown —
// load, search, uninstall, re-layout — funnels through here.
renderApps ($el_window, { preservePage = false } = {}) {
if ( ! this._apps ) return;
const $container = $el_window.find('.myapps-container');
const query = String($el_window.find('.myapps-search').val() || '').toLowerCase().trim();
let list = this._apps;
if ( query ) {
// Match the same values the tiles expose as data-app-name,
// data-app-title (the displayed title, e.g. the hostname for
// website shortcuts), and data-app-uid.
list = list.filter(app => {
const title = resolveTileDisplay(app).title.toLowerCase();
const rawTitle = (app.title || '').toLowerCase();
const name = (app.name || '').toLowerCase();
const uid = String(app.uid || app.uuid || '').toLowerCase();
return title.includes(query) || rawTitle.includes(query)
|| name.includes(query) || uid.includes(query);
});
}
if ( list.length === 0 ) {
this._layout = null;
this._page = 0;
this._pageCount = 0;
$container.html(query
? '<div class="myapps-empty"><p>No apps match your search</p></div>'
: buildNoAppsHtml());
return;
}
const layout = this.computeLayout($container);
if ( ! layout ) {
// Not laid out yet (e.g. hidden while the window enters full-page
// mode); the ResizeObserver re-renders once there's a size.
return;
}
const anchorIndex = (preservePage && this._layout)
? this._page * this._layout.perPage
: 0;
this._layout = layout;
this._pageCount = Math.ceil(list.length / layout.perPage);
this._page = Math.min(Math.floor(anchorIndex / layout.perPage), this._pageCount - 1);
$container.html(buildPagerHtml(list, layout));
revealWhenLoaded($container);
const scroller = $container.find('.myapps-pager-scroller')[0];
if ( this._page > 0 ) {
scroller.scrollLeft = this._page * scroller.clientWidth;
}
this.updatePagerUI($el_window);
// Keep the active dot and arrows in sync with swipes/scrolls.
const self = this;
let ticking = false;
scroller.addEventListener('scroll', () => {
if ( ticking ) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
const pageW = scroller.clientWidth || 1;
const idx = Math.round(Math.abs(scroller.scrollLeft) / pageW);
const page = Math.max(0, Math.min(self._pageCount - 1, idx));
if ( page !== self._page ) {
self._page = page;
self.updatePagerUI($el_window);
}
});
}, { passive: true });
},
updatePagerUI ($el_window) {
const $container = $el_window.find('.myapps-container');
const page = this._page;
$container.find('.myapps-pager-dot').each(function (i) {
this.classList.toggle('active', i === page);
if ( i === page ) {
this.setAttribute('aria-current', 'true');
} else {
this.removeAttribute('aria-current');
}
});
$container.find('.myapps-pager-arrow-prev')
.toggleClass('myapps-pager-arrow-hidden', page <= 0);
$container.find('.myapps-pager-arrow-next')
.toggleClass('myapps-pager-arrow-hidden', page >= this._pageCount - 1);
},
goToPage ($el_window, index, smooth) {
const scroller = $el_window.find('.myapps-pager-scroller')[0];
if ( ! scroller || this._pageCount === 0 ) return;
const page = Math.max(0, Math.min(this._pageCount - 1, index));
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
scroller.scrollTo({
left: page * scroller.clientWidth,
behavior: (smooth && !reduceMotion) ? 'smooth' : 'auto',
});
},
async loadApps ($el_window) {
@@ -340,8 +544,7 @@ const TabApps = {
}
this._apps = merged;
$container.html(buildAppsGrid(merged));
revealWhenLoaded($container);
this.renderApps($el_window);
} catch (e) {
console.error('Failed to load installed apps:', e);
$container.html('<div class="myapps-empty"><p>Failed to load apps</p></div>');
+3 -3
View File
@@ -1309,7 +1309,7 @@ async function UIDesktop (options) {
// /settings redirects to /dashboard
//--------------------------------------------------------------------------------------
else if ( window.url_paths[0]?.toLocaleLowerCase() === 'settings' ) {
window.open('/dashboard', '_blank');
window.open('/dashboard/#home', '_blank');
}
// ---------------------------------------------
// Run apps from insta-login URL
@@ -2169,7 +2169,7 @@ $(document).on('click', '.user-options-menu-btn', async function (e) {
html: i18n('settings'),
id: 'settings',
onClick: async function () {
window.open('/dashboard', '_blank');
window.open('/dashboard/#home', '_blank');
},
},
//--------------------------------------------------
@@ -2256,7 +2256,7 @@ $(document).on('click', '.search-btn', function () {
});
$(document).on('click', '.toolbar-puter-logo', function () {
window.open('/dashboard', '_blank');
window.open('/dashboard/#home', '_blank');
});
$(document).on('click', '.user-options-create-account-btn', async function (e) {
+1 -1
View File
@@ -1190,7 +1190,7 @@ async function UIItem (options) {
continue;
}
items.push({
html: suggested_app.title,
html: html_encode(suggested_app.title),
icon: `<img src="${html_encode(suggested_app.icon ?? window.icons['app.svg'])}" style="width:16px; height: 16px; margin-bottom: -4px;">`,
onClick: async function () {
var extension = path.extname($(el_item).attr('data-path')).toLowerCase();
+2 -2
View File
@@ -1017,7 +1017,7 @@ async function UIWindow (options) {
// build item for context menu
items.push({
html: `<span>${history_item === window.home_path ? i18n('home') : path.basename(history_item)}</span>`,
html: `<span>${history_item === window.home_path ? i18n('home') : html_encode(path.basename(history_item))}</span>`,
val: index,
onClick: async function (e) {
let history_index = e.value;
@@ -1090,7 +1090,7 @@ async function UIWindow (options) {
// build item for context menu
items.push({
html: `<span>${history_item === window.home_path ? 'Home' : path.basename(history_item)}</span>`,
html: `<span>${history_item === window.home_path ? 'Home' : html_encode(path.basename(history_item))}</span>`,
val: index,
onClick: async function (e) {
let history_index = e.value;
+17
View File
@@ -21,6 +21,7 @@ import TeePromise from '../util/TeePromise.js';
import UIWindow from './UIWindow.js';
import UIWindowRecoverPassword from './UIWindowRecoverPassword.js';
import UIWindowSignup from './UIWindowSignup.js';
import { KNOWN_OIDC_PROVIDERS, OIDC_GENERIC_PROVIDER_ICON, humanizeOidcProviderId } from '../util/openid.js';
// ── 2FA Login CSS (injected once) ───────────────────────────────────────────
const LOGIN_2FA_CSS = `
@@ -316,6 +317,7 @@ async function UIWindowLogin (options) {
h += `<button type="button" class="oidc-google-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA" />${i18n('sign_in_with_google')}</button>`;
h += `<button type="button" class="oidc-microsoft-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 23 23" fill="none"><rect x="1" y="1" width="10" height="10" fill="#f25022"/><rect x="12" y="1" width="10" height="10" fill="#7fba00"/><rect x="1" y="12" width="10" height="10" fill="#00a4ef"/><rect x="12" y="12" width="10" height="10" fill="#ffb900"/></svg>${i18n('sign_in_with_microsoft')}</button>`;
h += `<button type="button" class="oidc-apple-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_in_with_apple')}</button>`;
h += '<div class="oidc-custom-providers"></div>';
h += '</div>';
h += '</form>';
h += '</div>';
@@ -432,6 +434,21 @@ async function UIWindowLogin (options) {
if ( data.providers.includes('google') ) { hasProvider = true; wireOidcBtn('google', '.oidc-google-btn'); }
if ( data.providers.includes('apple') ) { hasProvider = true; wireOidcBtn('apple', '.oidc-apple-btn'); }
if ( data.providers.includes('microsoft') ) { hasProvider = true; wireOidcBtn('microsoft', '.oidc-microsoft-btn'); }
// Any other configured provider (e.g. a self-hosted admin's
// custom OIDC IdP) gets a generic button.
data.providers
.filter(provider => !KNOWN_OIDC_PROVIDERS.includes(provider))
.forEach((provider, idx) => {
hasProvider = true;
const btnClass = `oidc-custom-btn-${idx}`;
const label = humanizeOidcProviderId(provider);
$(el_window).find('.oidc-custom-providers').append(
`<button type="button" class="${btnClass} button button-block button-normal" style="display:flex; align-items:center; justify-content:center; gap:8px; margin-top:8px;">${OIDC_GENERIC_PROVIDER_ICON}${i18n('sign_in_with_provider', [label])}</button>`,
);
wireOidcBtn(provider, `.${btnClass}`);
});
if ( hasProvider ) $(el_window).find('.oidc-providers-wrapper').show();
} catch (_) {
}
+4 -2
View File
@@ -158,10 +158,12 @@ async function UIWindowSaveAccount (options) {
success: async function (data) {
window.dispatchEvent(new CustomEvent('account-saved', { detail: { data: data } }));
await window.update_auth_data(data.token, data.user);
// /save_account promotes the current session's user in place
// and does not rotate the token, so keep the one we have.
await window.update_auth_data(data.token ?? window.auth_token, data.user);
//close this window
if ( data.user.email_confirmation_required ) {
if ( data.user.requires_email_confirmation && !data.user.email_confirmed ) {
let is_verified = await UIWindowEmailConfirmationRequired({
stay_on_top: true,
has_head: true,
+454 -293
View File
@@ -23,8 +23,9 @@ import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequir
import UIWindowPhoneVerificationRequired from './UIWindowPhoneVerificationRequired.js';
import UIWindowCardVerificationRequired from './UIWindowCardVerificationRequired.js';
import UIWindowLogin from './UIWindowLogin.js';
import { KNOWN_OIDC_PROVIDERS, OIDC_GENERIC_PROVIDER_ICON, humanizeOidcProviderId } from '../util/openid.js';
function UIWindowSignup (options) {
function UIWindowSignup(options) {
options = options ?? {};
options.reload_on_success = options.reload_on_success ?? true;
options.has_head = options.has_head ?? true;
@@ -34,15 +35,16 @@ function UIWindowSignup (options) {
return new Promise(async (resolve) => {
const internal_id = window.uuidv4();
const logo_clickable = !!options.window_options?.cover_page && !window.embedded_in_popup;
const logo_clickable =
!!options.window_options?.cover_page && !window.embedded_in_popup;
let h = '';
h += '<div style="margin: 0 auto; max-width: 500px; min-width: 400px;">';
h +=
'<div style="margin: 0 auto; max-width: 500px; min-width: 400px;">';
// logo
h += `<img src="${window.icons['logo-white.svg']}" class="auth-logo" style="width: 40px; height: 40px; margin: 0 auto; display: block; padding: 10px; background-color: blue; border-radius: 5px;${logo_clickable ? ' cursor: pointer;' : ''}">`;
// close button
if ( !options.has_head && options.show_close_button !== false )
{
if (!options.has_head && options.show_close_button !== false) {
h += '<div class="generic-close-window-button"> &times; </div>';
}
@@ -66,7 +68,8 @@ function UIWindowSignup (options) {
h += `<input id="email-${internal_id}" value="${html_encode(options.email ?? '')}" class="email" type="email" autocomplete="email" spellcheck="false" autocorrect="off" autocapitalize="off" data-gramm_editor="false"/>`;
h += '</div>';
// password
h += '<div style="overflow: hidden; margin-top: 10px; position: relative;">';
h +=
'<div style="overflow: hidden; margin-top: 10px; position: relative;">';
h += `<label for="password-${internal_id}">${i18n('password')}</label>`;
h += `<input id="password-${internal_id}" class="password" type="${options.show_password ? 'text' : 'password'}" name="password" autocomplete="new-password" />`;
// show/hide icon
@@ -75,7 +78,8 @@ function UIWindowSignup (options) {
</span>`;
h += '</div>';
// confirm password
h += '<div style="overflow: hidden; margin-top: 10px; margin-bottom: 10px; position: relative">';
h +=
'<div style="overflow: hidden; margin-top: 10px; margin-bottom: 10px; position: relative">';
h += `<label for="confirm-password-${internal_id}">${i18n('signup_confirm_password')}</label>`;
h += `<input id="confirm-password-${internal_id}" class="confirm-password" type="${options.show_password ? 'text' : 'password'}" name="confirm-password" autocomplete="new-password" />`;
// show/hide icon
@@ -84,11 +88,13 @@ function UIWindowSignup (options) {
</span>`;
h += '</div>';
// bot trap - if this value is submitted server will ignore the request
h += '<input type="text" name="p102xyzname" class="p102xyzname" value="">';
h +=
'<input type="text" name="p102xyzname" class="p102xyzname" value="">';
// Turnstile widget (only when enabled)
if ( window.gui_params?.turnstileSiteKey ) {
h += '<div style="min-height: 20px; display: flex; justify-content: center;">';
if (window.gui_params?.turnstileSiteKey) {
h +=
'<div style="min-height: 20px; display: flex; justify-content: center;">';
// appearance: always/execute/interaction-only
// docs: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/?utm_source=chatgpt.com#appearance-modes
h += `<div class="cf-turnstile" data-sitekey="${window.gui_params.turnstileSiteKey}" data-appearance="interaction-only"></div>`;
@@ -98,10 +104,12 @@ function UIWindowSignup (options) {
// Create Account
h += `<button class="signup-btn button button-primary button-block button-normal">${i18n('create_free_account')}</button>`;
h += '</form>';
h += '<div class="oidc-providers-wrapper" style="display:none; padding: 10px 0;">';
h +=
'<div class="oidc-providers-wrapper" style="display:none; padding: 10px 0;">';
h += `<button type="button" class="oidc-google-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA" />${i18n('sign_up_with_google')}</button>`;
h += `<button type="button" class="oidc-microsoft-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 23 23" fill="none"><rect x="1" y="1" width="10" height="10" fill="#f25022"/><rect x="12" y="1" width="10" height="10" fill="#7fba00"/><rect x="1" y="12" width="10" height="10" fill="#00a4ef"/><rect x="12" y="12" width="10" height="10" fill="#ffb900"/></svg>${i18n('sign_up_with_microsoft')}</button>`;
h += `<button type="button" class="oidc-apple-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_up_with_apple')}</button>`;
h += '<div class="oidc-custom-providers"></div>';
h += '</div>';
// "Sign up with email" button, shown only when OIDC providers are available
h += `<div class="signup-with-email-wrapper" style="display:none;">`;
@@ -139,38 +147,63 @@ function UIWindowSignup (options) {
dominant: true,
center: true,
onAppend: function (el_window) {
if ( options.authError ) {
$(el_window).find('.signup-error-msg').html(html_encode(options.authError)).fadeIn();
if (options.authError) {
$(el_window)
.find('.signup-error-msg')
.html(html_encode(options.authError))
.fadeIn();
}
if ( ! window.disable_signup_autofocus )
{
$(el_window).find('.username').get(0).focus({ preventScroll: true });
if (!window.disable_signup_autofocus) {
$(el_window)
.find('.username')
.get(0)
.focus({ preventScroll: true });
}
if ( logo_clickable ) {
$(el_window).find('.auth-logo').on('click', function () {
// keep desktop users on the desktop; everyone else goes to the root dashboard
window.location.href = window.location.pathname === '/desktop' ? '/desktop' : '/';
});
if (logo_clickable) {
$(el_window)
.find('.auth-logo')
.on('click', function () {
// keep desktop users on the desktop; everyone else goes to the root dashboard
window.location.href =
window.location.pathname === '/desktop'
? '/desktop'
: '/';
});
}
// Initialize Turnstile widget with callback to capture token
const initTurnstile = () => {
if ( window.turnstile && window.gui_params?.turnstileSiteKey ) {
if (
window.turnstile &&
window.gui_params?.turnstileSiteKey
) {
window.turnstile.render('.cf-turnstile', {
sitekey: window.gui_params.turnstileSiteKey,
callback: function (token) {
// Store the token for the signup request
$(el_window).find('.cf-turnstile').attr('data-token', token);
$(el_window)
.find('.cf-turnstile')
.attr('data-token', token);
// Enable the signup button once CAPTCHA is completed
$(el_window).find('.signup-btn').prop('disabled', false);
$(el_window)
.find('.signup-btn')
.prop('disabled', false);
// Add visual feedback
$(el_window).find('.cf-turnstile').addClass('captcha-completed');
$(el_window)
.find('.cf-turnstile')
.addClass('captcha-completed');
},
'expired-callback': function () {
// Reset when token expires
$(el_window).find('.cf-turnstile').removeAttr('data-token');
$(el_window).find('.cf-turnstile').removeClass('captcha-completed');
$(el_window).find('.signup-btn').prop('disabled', true);
$(el_window)
.find('.cf-turnstile')
.removeAttr('data-token');
$(el_window)
.find('.cf-turnstile')
.removeClass('captcha-completed');
$(el_window)
.find('.signup-btn')
.prop('disabled', true);
},
});
} else {
@@ -183,53 +216,104 @@ function UIWindowSignup (options) {
(async () => {
try {
const res = await fetch(`${window.api_origin}/auth/oidc/providers`);
if ( ! res.ok ) return;
const res = await fetch(
`${window.api_origin}/auth/oidc/providers`,
);
if (!res.ok) return;
const data = await res.json();
if ( ! data.providers ) return;
if (!data.providers) return;
const wireOidcBtn = (provider, btnClass) => {
$(el_window).find(btnClass).css('display', 'flex');
$(el_window).find(btnClass).on('click', function () {
let url = `${window.gui_origin}/auth/oidc/${provider}/start?flow=signup`;
// return to the interface the signup started from (backend whitelists the path)
const return_to = window.location.pathname;
if ( return_to === '/desktop' || return_to === '/dashboard' ) {
url += `&return_to=${encodeURIComponent(return_to)}`;
}
const referrer = options.referrer ?? window.referrerStr;
if ( referrer ) {
url += `&referrer=${encodeURIComponent(referrer)}`;
}
if ( window.embedded_in_popup && window.url_query_params?.get('msg_id') ) {
url += `&embedded_in_popup=true&msg_id=${encodeURIComponent(window.url_query_params.get('msg_id'))}`;
if ( window.openerOrigin ) {
url += `&opener_origin=${encodeURIComponent(window.openerOrigin)}`;
$(el_window)
.find(btnClass)
.on('click', function () {
let url = `${window.gui_origin}/auth/oidc/${provider}/start?flow=signup`;
// return to the interface the signup started from (backend whitelists the path)
const return_to = window.location.pathname;
if (
return_to === '/desktop' ||
return_to === '/dashboard'
) {
url += `&return_to=${encodeURIComponent(return_to)}`;
}
}
window.location.href = url;
});
const referrer =
options.referrer ?? window.referrerStr;
if (referrer) {
url += `&referrer=${encodeURIComponent(referrer)}`;
}
if (
window.embedded_in_popup &&
window.url_query_params?.get('msg_id')
) {
url += `&embedded_in_popup=true&msg_id=${encodeURIComponent(window.url_query_params.get('msg_id'))}`;
if (window.openerOrigin) {
url += `&opener_origin=${encodeURIComponent(window.openerOrigin)}`;
}
}
window.location.href = url;
});
};
let hasProvider = false;
if ( data.providers.includes('google') ) { hasProvider = true; wireOidcBtn('google', '.oidc-google-btn'); }
if ( data.providers.includes('apple') ) { hasProvider = true; wireOidcBtn('apple', '.oidc-apple-btn'); }
if ( data.providers.includes('microsoft') ) { hasProvider = true; wireOidcBtn('microsoft', '.oidc-microsoft-btn'); }
if ( hasProvider ) {
if (data.providers.includes('google')) {
hasProvider = true;
wireOidcBtn('google', '.oidc-google-btn');
}
if (data.providers.includes('apple')) {
hasProvider = true;
wireOidcBtn('apple', '.oidc-apple-btn');
}
if (data.providers.includes('microsoft')) {
hasProvider = true;
wireOidcBtn('microsoft', '.oidc-microsoft-btn');
}
// Any other configured provider (e.g. a self-hosted
// admin's custom OIDC IdP) gets a generic button.
data.providers
.filter(
provider =>
!KNOWN_OIDC_PROVIDERS.includes(provider),
)
.forEach((provider, idx) => {
hasProvider = true;
const btnClass = `oidc-custom-btn-${idx}`;
const label =
humanizeOidcProviderId(provider);
$(el_window)
.find('.oidc-custom-providers')
.append(
`<button type="button" class="${btnClass} button button-block button-normal" style="display:flex; align-items:center; justify-content:center; gap:8px; margin-top:8px;">${OIDC_GENERIC_PROVIDER_ICON}${i18n('sign_up_with_provider', [label])}</button>`,
);
wireOidcBtn(provider, `.${btnClass}`);
});
if (hasProvider) {
$(el_window).find('.oidc-providers-wrapper').show();
// Hide the signup form and show the "Sign up with email" button
$(el_window).find('.signup-form').hide();
$(el_window).find('.signup-form-title').hide();
$(el_window).find('.signup-with-email-wrapper').show();
$(el_window).find('.signup-with-email-btn').on('click', function () {
$(el_window).find('.signup-form').show();
$(el_window).find('.signup-form-title').show();
$(el_window).find('.signup-with-email-wrapper').hide();
$(el_window).find('.username').get(0)?.focus({ preventScroll: true });
});
$(el_window)
.find('.signup-with-email-wrapper')
.show();
$(el_window)
.find('.signup-with-email-btn')
.on('click', function () {
$(el_window).find('.signup-form').show();
$(el_window)
.find('.signup-form-title')
.show();
$(el_window)
.find('.signup-with-email-wrapper')
.hide();
$(el_window)
.find('.username')
.get(0)
?.focus({ preventScroll: true });
});
}
} catch (_) {
}
} catch (_) {}
})();
},
window_class: 'window-signup',
@@ -240,7 +324,7 @@ function UIWindowSignup (options) {
width: 'initial',
'background-color': 'white',
'backdrop-filter': 'blur(3px)',
'display': 'flex',
display: 'flex',
'flex-direction': 'column',
'justify-content': 'center',
'align-items': 'center',
@@ -260,272 +344,349 @@ function UIWindowSignup (options) {
`,
});
$(el_window).find('.login-c2a-clickable').on('click', async function (e) {
$('.login-c2a-clickable').parents('.window').close();
const login = await UIWindowLogin({
referrer: options.referrer,
reload_on_success: options.reload_on_success,
redirect_url: options.redirect_url,
window_options: options.window_options,
show_close_button: options.show_close_button,
send_confirmation_code: options.send_confirmation_code,
show_password: false,
$(el_window)
.find('.login-c2a-clickable')
.on('click', async function (e) {
$('.login-c2a-clickable').parents('.window').close();
const login = await UIWindowLogin({
referrer: options.referrer,
reload_on_success: options.reload_on_success,
redirect_url: options.redirect_url,
window_options: options.window_options,
show_close_button: options.show_close_button,
send_confirmation_code: options.send_confirmation_code,
show_password: false,
});
if (login) {
resolve(true);
}
});
if ( login )
{
resolve(true);
}
});
$(el_window).find('.signup-btn').on('click', async function (e) {
// Clear previous error states
$(el_window).find('.signup-error-msg').hide();
$(el_window)
.find('.signup-btn')
.on('click', async function (e) {
// Clear previous error states
$(el_window).find('.signup-error-msg').hide();
//Username
let username = $(el_window).find('.username').val();
//Username
let username = $(el_window).find('.username').val();
if ( ! username ) {
$(el_window).find('.signup-error-msg').html(i18n('username_required'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
//Email
let email = $(el_window).find('.email').val();
// must have an email
if ( ! email ) {
$(el_window).find('.signup-error-msg').html(i18n('email_required'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// must be a valid email
else if ( ! window.is_email(email) ) {
$(el_window).find('.signup-error-msg').html(i18n('email_invalid'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
//Password
let password = $(el_window).find('.password').val();
// must have a password
if ( ! password ) {
$(el_window).find('.signup-error-msg').html(i18n('password_required'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// check password strength
const pass_strength = check_password_strength(password);
if ( ! pass_strength.overallPass ) {
$(el_window).find('.signup-error-msg').html(i18n('password_strength_error'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// get confirm password value
const confirmPassword = $(el_window).find('.confirm-password').val();
if ( ! confirmPassword ) {
$(el_window).find('.signup-error-msg').html(i18n('confirm_password_required'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// check if passwords match
if ( password !== confirmPassword ) {
$(el_window).find('.signup-error-msg').html(i18n('passwords_do_not_match'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// Check if Cloudflare Turnstile CAPTCHA was completed
let turnstileToken = null;
if ( window.turnstile && window.gui_params?.turnstileSiteKey ) {
turnstileToken = $(el_window).find('.cf-turnstile').attr('data-token');
if ( ! turnstileToken ) {
$(el_window).find('.signup-error-msg').html(i18n('captcha_required') || 'Please complete the CAPTCHA verification');
if (!username) {
$(el_window)
.find('.signup-error-msg')
.html(i18n('username_required'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
}
//xyzname
let p102xyzname = $(el_window).find('.p102xyzname').val();
//Email
let email = $(el_window).find('.email').val();
// disable 'Create Account' button
$(el_window).find('.signup-btn').prop('disabled', true);
// must have an email
if (!email) {
$(el_window)
.find('.signup-error-msg')
.html(i18n('email_required'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// must be a valid email
else if (!window.is_email(email)) {
$(el_window)
.find('.signup-error-msg')
.html(i18n('email_invalid'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
let headers = {};
if ( window.custom_headers )
{
headers = window.custom_headers;
}
//Password
let password = $(el_window).find('.password').val();
// Device signal for abuse prevention; omitted when unavailable
let fingerprint = null;
try {
fingerprint = await window.getDeviceFingerprint?.();
} catch (_) {
// signup must never block or fail because of device signals
}
// must have a password
if (!password) {
$(el_window)
.find('.signup-error-msg')
.html(i18n('password_required'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// check password strength
const pass_strength = check_password_strength(password);
if (!pass_strength.overallPass) {
$(el_window)
.find('.signup-error-msg')
.html(i18n('password_strength_error'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// get confirm password value
const confirmPassword = $(el_window)
.find('.confirm-password')
.val();
if (!confirmPassword) {
$(el_window)
.find('.signup-error-msg')
.html(i18n('confirm_password_required'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// check if passwords match
if (password !== confirmPassword) {
$(el_window)
.find('.signup-error-msg')
.html(i18n('passwords_do_not_match'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// Include captcha in request only if required
const requestData = {
username: username,
email: email,
password: password,
referrer: options.referrer ?? window.referrerStr,
send_confirmation_code: options.send_confirmation_code,
p102xyzname: p102xyzname,
'cf-turnstile-response': turnstileToken,
};
if ( fingerprint ) {
requestData.fingerprint = fingerprint;
}
// Check if Cloudflare Turnstile CAPTCHA was completed
let turnstileToken = null;
if (window.turnstile && window.gui_params?.turnstileSiteKey) {
turnstileToken = $(el_window)
.find('.cf-turnstile')
.attr('data-token');
if (!turnstileToken) {
$(el_window)
.find('.signup-error-msg')
.html(
i18n('captcha_required') ||
'Please complete the CAPTCHA verification',
);
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
}
$.ajax({
url: `${window.gui_origin }/signup`,
type: 'POST',
async: true,
headers: headers,
contentType: 'application/json',
data: JSON.stringify(requestData),
success: async function (data) {
await window.update_auth_data(data.token, data.user);
//xyzname
let p102xyzname = $(el_window).find('.p102xyzname').val();
// disable 'Create Account' button
$(el_window).find('.signup-btn').prop('disabled', true);
let headers = {};
if (window.custom_headers) {
headers = window.custom_headers;
}
// Device signal for abuse prevention; omitted when unavailable
let fingerprint = null;
try {
fingerprint = await window.getDeviceFingerprint?.();
} catch (_) {
// signup must never block or fail because of device signals
}
// Include captcha in request only if required
const requestData = {
username: username,
email: email,
password: password,
referrer: options.referrer ?? window.referrerStr,
send_confirmation_code: options.send_confirmation_code,
p102xyzname: p102xyzname,
'cf-turnstile-response': turnstileToken,
};
if (fingerprint) {
requestData.fingerprint = fingerprint;
}
$.ajax({
url: `${window.gui_origin}/signup`,
type: 'POST',
async: true,
headers: headers,
contentType: 'application/json',
data: JSON.stringify(requestData),
success: async function (data) {
await window.update_auth_data(data.token, data.user);
//send out the login event
if ( options.reload_on_success ) {
window.onbeforeunload = null;
// either options.redirect_url or the current page
const redirectUrl = options.redirect_url || '/';
window.location.replace(redirectUrl);
} else if ( data.user?.requires_phone_verification || data.user?.requires_card_verification || options.send_confirmation_code || data.user?.requires_email_confirmation ) {
$(el_window).close();
// Low-reputation signups must clear every flagged gate.
// Phone (SMS) and email come first; the card gate only
// shows once those are cleared.
if ( data.user?.requires_phone_verification ) {
let phone_ok = false;
do {
phone_ok = await UIWindowPhoneVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: true,
window_options: options.window_options ?? {},
});
}
while ( !phone_ok );
}
let email_verified = true;
if ( options.send_confirmation_code || data.user?.requires_email_confirmation ) {
email_verified = await UIWindowEmailConfirmationRequired({
stay_on_top: true,
has_head: true,
reload_on_success: options.reload_on_success,
window_options: options.window_options ?? {},
});
}
// Card verification is the last gate.
if ( data.user?.requires_card_verification ) {
let card_ok = false;
do {
card_ok = await UIWindowCardVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: true,
window_options: options.window_options ?? {},
});
const show_email_dialog =
!options.reload_on_success &&
(options.send_confirmation_code ||
data.user?.requires_email_confirmation);
if (
data.user?.requires_phone_verification ||
data.user?.requires_card_verification ||
show_email_dialog
) {
$(el_window).close();
// Verification gates run in order: email →
// phone (SMS) → card, matching the server-side
// order in assertVerifiedAccount.
if (show_email_dialog) {
email_verified =
await UIWindowEmailConfirmationRequired({
stay_on_top: true,
has_head: true,
reload_on_success:
options.reload_on_success,
window_options:
options.window_options ?? {},
});
}
if (data.user?.requires_phone_verification) {
let phone_ok = false;
do {
phone_ok =
await UIWindowPhoneVerificationRequired(
{
show_close_button: false,
stay_on_top: true,
has_head: true,
window_options:
options.window_options ??
{},
},
);
} while (!phone_ok);
}
// Card verification is the last gate.
if (data.user?.requires_card_verification) {
let card_ok = false;
do {
card_ok =
await UIWindowCardVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: true,
window_options:
options.window_options ?? {},
});
} while (!card_ok);
}
while ( !card_ok );
}
resolve(email_verified);
} else {
resolve(true);
}
},
error: function (err) {
// re-enable 'Create Account' button so user can try again
$(el_window).find('.signup-btn').prop('disabled', false);
// Reset Turnstile widget for retry
try {
if ( window.turnstile ) {
window.turnstile?.reset('.cf-turnstile');
$(el_window).find('.cf-turnstile').removeAttr('data-token');
$(el_window).find('.cf-turnstile').removeClass('captcha-completed');
if (options.reload_on_success) {
window.onbeforeunload = null;
// either options.redirect_url or the current page
const redirectUrl = options.redirect_url || '/';
window.location.replace(redirectUrl);
} else {
resolve(email_verified);
}
} catch (e) {
console.log(e);
}
},
error: function (err) {
// re-enable 'Create Account' button so user can try again
$(el_window)
.find('.signup-btn')
.prop('disabled', false);
// Process error response
const errorText = err.responseText || '';
// Reset Turnstile widget for retry
try {
if (window.turnstile) {
window.turnstile?.reset('.cf-turnstile');
$(el_window)
.find('.cf-turnstile')
.removeAttr('data-token');
$(el_window)
.find('.cf-turnstile')
.removeClass('captcha-completed');
}
} catch (e) {
console.log(e);
}
// Handle JSON error response
try {
// Try to parse error as JSON
const errorJson = JSON.parse(errorText);
// Process error response
const errorText = err.responseText || '';
// Handle signup blocked with full-screen overlay
if ( errorJson?.code === 'signup_blocked' ) {
const overlay = document.createElement('div');
overlay.classList.add('signup-blocked-overlay');
const blockedMsg = errorJson.message || 'Signup Not Allowed';
overlay.innerHTML = `
// Handle JSON error response
try {
// Try to parse error as JSON
const errorJson = JSON.parse(errorText);
// Handle signup blocked with full-screen overlay
if (errorJson?.code === 'signup_blocked') {
const overlay = document.createElement('div');
overlay.classList.add('signup-blocked-overlay');
const blockedMsg =
errorJson.message || 'Signup Not Allowed';
overlay.innerHTML = `
<div class="signup-blocked-content">
<img src="${window.icons['logo.svg'] || window.icons['logo-white.svg'] || ''}" style="width:64px;margin-bottom:24px;" />
<p>${html_encode(blockedMsg)}</p>
<p>If you already have an account, try <a href="/action/login">logging in</a>. Otherwise, contact <a href="mailto:support@puter.com">support@puter.com</a> for assistance.</p>
</div>
`;
document.body.appendChild(overlay);
return;
document.body.appendChild(overlay);
return;
}
// Handle timeout specifically
if (
errorJson?.code === 'response_timeout' ||
errorText.includes('timeout')
) {
$(el_window)
.find('.signup-error-msg')
.html(
i18n('server_timeout') ||
'The server took too long to respond. Please try again.',
);
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// If it's a message in the JSON, use that
if (errorJson.message) {
$(el_window)
.find('.signup-error-msg')
.html(errorJson.message);
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
} catch (e) {
console.log(e);
// Not JSON, continue with text analysis
}
// Handle timeout specifically
if ( errorJson?.code === 'response_timeout' || errorText.includes('timeout') ) {
$(el_window).find('.signup-error-msg').html(i18n('server_timeout') || 'The server took too long to respond. Please try again.');
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// If it's a message in the JSON, use that
if ( errorJson.message ) {
$(el_window).find('.signup-error-msg').html(errorJson.message);
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
} catch (e) {
console.log(e);
// Not JSON, continue with text analysis
}
// Default general error handling
$(el_window).find('.signup-error-msg').html(errorText || i18n('signup_error') || 'An error occurred during signup. Please try again.');
$(el_window).find('.signup-error-msg').fadeIn();
},
timeout: 30000, // Add a reasonable timeout
// Default general error handling
$(el_window)
.find('.signup-error-msg')
.html(
errorText ||
i18n('signup_error') ||
'An error occurred during signup. Please try again.',
);
$(el_window).find('.signup-error-msg').fadeIn();
},
timeout: 30000, // Add a reasonable timeout
});
});
});
$(el_window).find('.signup-form').on('submit', function (e) {
e.preventDefault();
e.stopPropagation();
return false;
});
$(el_window)
.find('.signup-form')
.on('submit', function (e) {
e.preventDefault();
e.stopPropagation();
return false;
});
$(el_window).find(`#toggle-show-password-${internal_id}, #toggle-show-password-${internal_id}-confirm`).on('click', function (e) {
// hide/show password/confirm password and update icon
let inputField = $(this).siblings('input');
let isPasswordVisible = inputField.attr('type') === 'text';
inputField.attr('type', isPasswordVisible ? 'password' : 'text');
$(this).find('.toggle-show-password-icon').attr(
'src',
isPasswordVisible ? window.icons['eye-open.svg'] : window.icons['eye-closed.svg'],
);
});
$(el_window)
.find(
`#toggle-show-password-${internal_id}, #toggle-show-password-${internal_id}-confirm`,
)
.on('click', function (e) {
// hide/show password/confirm password and update icon
let inputField = $(this).siblings('input');
let isPasswordVisible = inputField.attr('type') === 'text';
inputField.attr(
'type',
isPasswordVisible ? 'password' : 'text',
);
$(this)
.find('.toggle-show-password-icon')
.attr(
'src',
isPasswordVisible
? window.icons['eye-open.svg']
: window.icons['eye-closed.svg'],
);
});
//remove login window
$('.signup-c2a-clickable').parents('.window').close();
});
}
export default UIWindowSignup;
export default UIWindowSignup;
+172 -55
View File
@@ -630,40 +630,22 @@ body {
/* My Apps tab */
.myapps-tab {
max-width: 900px;
padding: 32px 40px;
width: 100%;
/* The Apps tab fills the content area and paginates horizontally
(iOS-home-screen style) instead of scrolling vertically. */
.dashboard-section-apps {
height: 100%;
}
.dashboard-tab-content.myapps-tab {
height: 100%;
display: flex;
flex-direction: column;
}
.myapps-search-wrap {
position: sticky;
top: 0;
z-index: 10;
padding-top: 30px;
padding-bottom: 30px;
margin-bottom: 20px;
margin-left: -40px;
margin-right: -40px;
padding-left: 40px;
padding-right: 40px;
background: var(--dashboard-background);
}
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.myapps-search-wrap {
background: rgba(255, 255, 255, 0);
backdrop-filter: blur(20px) saturate(1.5);
-webkit-backdrop-filter: blur(20px) saturate(1.5);
}
}
@media (prefers-color-scheme: dark) {
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.myapps-search-wrap {
background: rgba(30, 30, 30, 0.55);
}
}
}
.myapps-search-inner {
@@ -731,11 +713,20 @@ input.myapps-search::-webkit-search-decoration {
}
.myapps-container {
min-height: 200px;
padding-bottom: 50px;
/* Pager geometry; TabApps.js reads these to size pages. */
--myapps-tile-w: 100px;
--myapps-tile-h: 78px;
--myapps-gap-x: 32px;
--myapps-gap-y: 32px;
--myapps-dots-h: 28px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.myapps-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
@@ -756,19 +747,151 @@ input.myapps-search::-webkit-search-decoration {
margin: 0;
}
.myapps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 100px);
gap: 32px 32px;
justify-content: center;
/* iOS-style pager: pages snap horizontally, dots below, hover arrows */
.myapps-pager {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
opacity: 1;
transition: opacity 0.2s ease;
}
.myapps-grid-loading {
.myapps-pager-loading {
opacity: 0;
}
.myapps-pager-scroller {
flex: 1;
min-height: 0;
display: flex;
overflow-x: auto;
overflow-y: hidden;
/* overflow-x above forces vertical clipping too, which would shave the
top of first-row icons when they scale up on hover; bleed the clip
edge upward so the scaled icons stay visible. */
margin-top: -8px;
padding-top: 8px;
scroll-snap-type: x mandatory;
overscroll-behavior-x: contain;
touch-action: pan-x;
scrollbar-width: none;
}
.myapps-pager-scroller::-webkit-scrollbar {
display: none;
}
.myapps-page {
flex: 0 0 100%;
min-width: 0;
scroll-snap-align: start;
scroll-snap-stop: always;
display: grid;
grid-template-columns: repeat(var(--myapps-cols, 1), var(--myapps-tile-w));
grid-auto-rows: var(--myapps-tile-h);
column-gap: var(--myapps-gap-x);
row-gap: var(--myapps-gap-y);
justify-content: center;
align-content: start;
}
.myapps-pager-dots {
flex: none;
height: var(--myapps-dots-h);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.myapps-pager-dots-hidden {
visibility: hidden;
}
.myapps-pager-dot {
-webkit-appearance: none;
appearance: none;
flex: none;
width: 17px;
height: 17px;
padding: 5px;
margin: 0;
border: none;
border-radius: 50%;
background-color: var(--dashboard-text-primary);
background-clip: content-box;
opacity: 0.22;
cursor: pointer;
transition: opacity 0.2s ease;
}
.myapps-pager-dot.active {
opacity: 0.85;
}
@media (hover: hover) {
.myapps-pager-dot:not(.active):hover {
opacity: 0.5;
}
}
.myapps-pager-dot:focus-visible {
outline: 2px solid var(--select-color);
outline-offset: -1px;
}
.myapps-pager-arrow {
position: absolute;
top: calc(50% - var(--myapps-dots-h) / 2);
transform: translateY(-50%);
width: 32px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--dashboard-border);
border-radius: 50%;
background: var(--dashboard-card-background);
color: var(--dashboard-text-secondary);
box-shadow: 0 2px 8px var(--dashboard-shadow-light);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease, background 0.15s ease;
z-index: 2;
}
.myapps-pager-arrow svg {
width: 16px;
height: 16px;
}
.myapps-pager-arrow-prev {
left: 0;
}
.myapps-pager-arrow-next {
right: 0;
}
/* Arrows are a hover-only affordance for mouse users; touch swipes instead. */
@media (hover: hover) and (pointer: fine) {
.myapps-pager:hover .myapps-pager-arrow:not(.myapps-pager-arrow-hidden),
.myapps-pager-arrow:focus-visible:not(.myapps-pager-arrow-hidden) {
opacity: 1;
pointer-events: auto;
}
.myapps-pager-arrow:hover {
background: var(--dashboard-hover);
color: var(--dashboard-text-primary);
}
}
.myapps-tile {
display: flex;
flex-direction: column;
@@ -1025,19 +1148,22 @@ input.myapps-search::-webkit-search-decoration {
height: 40px;
}
.dashboard-tab-content.myapps-tab {
padding-bottom: max(6px, env(safe-area-inset-bottom));
}
.myapps-container {
padding: 20px 10px;
--myapps-tile-w: 72px;
--myapps-tile-h: 74px;
--myapps-gap-x: 12px;
--myapps-gap-y: 30px;
}
.myapps-tab h2 {
font-size: 22px;
margin-bottom: 16px;
}
.myapps-grid {
grid-template-columns: repeat(4, 72px);
column-gap: max(0px, calc((100% - 4 * 72px) / 3));
row-gap: 30px;
/* Spread the fixed column count across the page width, iOS-style;
--myapps-gap-x stays the minimum gap the column count is computed for. */
.myapps-page {
justify-content: space-between;
column-gap: 0;
}
.myapps-tile {
@@ -1049,15 +1175,6 @@ input.myapps-search::-webkit-search-decoration {
height: 52px;
border-radius: 12px;
}
/* Too narrow for 4×72px in one row: wrap like before */
@media (max-width: 400px) {
.myapps-grid {
grid-template-columns: repeat(auto-fill, 72px);
column-gap: 12px;
row-gap: 20px;
}
}
}
/* Full HD */
+1 -1
View File
@@ -1212,7 +1212,7 @@ window.available_templates = () => {
const itemStructure = {
path: _path,
html: `${extension.toUpperCase()} ${name}`,
html: `${extension.toUpperCase()} ${html_encode(name)}`,
extension: extension,
name: element.name,
};
@@ -659,7 +659,7 @@ async function generateOpenWithItems (el_item, fsentry, suggested_apps) {
continue;
}
items.push({
html: suggested_app.title,
html: html_encode(suggested_app.title),
icon: `<img src="${html_encode(suggested_app.icon ?? window.icons['app.svg'])}" style="width:16px; height: 16px; margin-bottom: -4px;">`,
onClick: async function () {
var extension = path.extname($(el_item).attr('data-path')).toLowerCase();
+7
View File
@@ -521,6 +521,11 @@ const en = {
'too_many_attempts': 'Too many attempts. Please try again later.',
'server_timeout': 'The server took too long to respond. Please try again.',
'signup_error': 'An error occurred during signup. Please try again.',
'auth_error_generic': 'Authentication failed. Please try again.',
'account_suspended_message': 'This account is suspended.',
'signup_blocked_message': 'Your account could not be created.',
'contact_support': 'Please contact [email protected].',
'contact_support_with_code': 'Please contact [email protected] and include this code: {{id}}.',
// Welcome Window
'welcome': 'Welcome',
@@ -553,6 +558,8 @@ const en = {
sign_up_with_apple: 'Sign up with Apple',
sign_in_with_microsoft: 'Sign in with Microsoft',
sign_up_with_microsoft: 'Sign up with Microsoft',
sign_in_with_provider: 'Sign in with %%',
sign_up_with_provider: 'Sign up with %%',
sign_up_with_email: 'Sign up using email',
oidc_switched_to_login_message: 'You have been logged in to an existing account.',
+855 -928
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -29,6 +29,21 @@ import TeePromise from './TeePromise.js';
const POPUP_FEATURES = 'width=500,height=600';
// Provider ids the GUI renders with dedicated branded buttons. Any other id
// returned by `/auth/oidc/providers` (i.e. a self-hosted admin's custom OIDC
// provider) gets a generic button built from these helpers instead.
export const KNOWN_OIDC_PROVIDERS = ['google', 'apple', 'microsoft'];
// Generic key icon for OIDC providers without dedicated branding.
export const OIDC_GENERIC_PROVIDER_ICON = '<svg style="width:20px;height:20px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="M10.5 12.5 19 4"/><path d="M16 7l3 3"/><path d="M13 10l3 3"/></svg>';
// Turns a config provider id ("custom-oidc", "acme_sso") into a display
// label ("Custom Oidc", "Acme Sso") for the generic button.
export const humanizeOidcProviderId = (id) => String(id)
.replace(/[-_]+/g, ' ')
.trim()
.replace(/\w\S*/g, word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
export const openRevalidatePopup = async (revalidateUrl) => {
const donePromise = new TeePromise();
+5
View File
@@ -4,6 +4,7 @@ import type { Apps, AppListOptions, AppRecord, CreateAppOptions, UpdateAppAttrib
import type { Auth, APIUsage, AllowanceInfo, AppUsage, AuthUser, DetailedAppUsage, MonthlyUsage } from './types/modules/auth.d.ts';
import type { Debug } from './types/modules/debug.d.ts';
import type { Driver, DriverDescriptor, Drivers } from './types/modules/drivers.d.ts';
import type { Email, EmailAttachment, EmailSendOptions, EmailSendResult } from './types/modules/email.d.ts';
import type { FS, CopyOptions, DeleteOptions, MkdirOptions, MoveOptions, ReadOptions, ReaddirOptions, SignResult, SpaceInfo, UploadOptions, WriteOptions } from './types/modules/filesystem.d.ts';
import type { FSItem, FileSignatureInfo, InternalFSProperties } from './types/modules/fs-item.d.ts';
import type { Hosting, Subdomain } from './types/modules/hosting.d.ts';
@@ -59,6 +60,10 @@ export type {
Driver,
DriverDescriptor,
Drivers,
Email,
EmailAttachment,
EmailSendOptions,
EmailSendResult,
FSItem,
FilePickerOptions,
FileSignatureInfo,
+79 -23
View File
@@ -1,5 +1,6 @@
import kvjs from '@heyputer/kv.js';
import APICallLogger from './lib/APICallLogger.js';
import { isStoredTokenUsableForOrigin } from './lib/authTokenOrigin.js';
import path from './lib/path.js';
import localStorageMemory from './lib/polyfills/localStorage.js';
import xhrshim from './lib/polyfills/xhrshim.js';
@@ -9,6 +10,7 @@ import Apps from './modules/Apps.js';
import Auth from './modules/Auth.js';
import { Debug } from './modules/Debug.js';
import Drivers from './modules/Drivers.js';
import Email from './modules/Email.js';
import { PuterJSFileSystemModule } from './modules/FileSystem/index.js';
import FSItem from './modules/FSItem.js';
import Hosting from './modules/Hosting.js';
@@ -99,6 +101,10 @@ const PROD_ORIGIN = 'https://puter.com';
// to the interactive reauth flow.
const STORAGE_KEY_V1 = 'puter.auth.token';
const STORAGE_KEY_V2 = 'puter.auth.token.v2';
// Records the API origin a stored v2 token was minted against. A stored token
// is only ever replayed to this origin, so a URL-controlled `puter.api_origin`
// can't harvest a previously-stored token and forward it to a foreign origin.
const STORAGE_KEY_ORIGIN_V2 = 'puter.auth.token.origin.v2';
const puterInit = function () {
'use strict';
@@ -197,6 +203,7 @@ const puterInit = function () {
this.registerModule('apps', Apps);
this.registerModule('ai', AI);
this.registerModule('kv', KV);
this.registerModule('email', Email);
this.registerModule('perms', Perms);
this.registerModule('drivers', Drivers);
this.registerModule('debug', Debug);
@@ -504,23 +511,44 @@ const puterInit = function () {
this.setAuthToken(bootstrapAuthToken);
needsSilentMigration = true;
} else {
// Prefer the v2 storage key. Fall back to v1
// and queue a silent migrate-token call.
// No token in the URL — fall back to a stored token,
// but ONLY if it is allowed for the current API origin.
// In app mode `puter.api_origin` is URL-controlled, so a
// stored token must be bound to (and matched against) the
// origin it was minted for. A custom (non-default) origin
// additionally requires an explicit binding; an unbound
// legacy token is only honored against the default origin.
const boundOrigin = this.normalizeStringCandidate(
localStorage.getItem(STORAGE_KEY_ORIGIN_V2),
);
const v2 = this.normalizeAuthTokenCandidate(
localStorage.getItem(STORAGE_KEY_V2),
);
if (v2) {
this.setAuthToken(v2);
selectedAuthToken = v2;
} else {
const v1 = this.normalizeAuthTokenCandidate(
localStorage.getItem(STORAGE_KEY_V1),
);
if (v1) {
this.setAuthToken(v1);
selectedAuthToken = v1;
needsSilentMigration = true;
}
// v1 (legacy) tokens never carry an origin binding.
const v1 = v2
? null
: this.normalizeAuthTokenCandidate(
localStorage.getItem(STORAGE_KEY_V1),
);
const storedToken = v2 ?? v1;
if (
storedToken &&
this._storedTokenUsableForCurrentOrigin(
v2 ? boundOrigin : null,
)
) {
this.setAuthToken(storedToken);
selectedAuthToken = storedToken;
if (v1) needsSilentMigration = true;
} else if (storedToken) {
// A token exists but is not valid for this API
// origin (a URL-supplied custom/attacker origin, or
// an unbound legacy token against a custom origin).
// Treat as unauthenticated and force a reauth for
// this origin instead of replaying the token.
this._needsOriginReauth = {
reason: 'api_origin_mismatch',
};
}
}
if (needsSilentMigration && selectedAuthToken) {
@@ -545,6 +573,18 @@ const puterInit = function () {
console.error('Error accessing localStorage:', error);
}
this.initSubmodules();
if (this._needsOriginReauth) {
const reauthSignal = this._needsOriginReauth;
this._needsOriginReauth = null;
// The URL-supplied API origin was rejected as untrusted, so
// snap the API origin back to the trusted default before
// reauthing. This guarantees the fresh token is bound to —
// and only ever sent to — the trusted default origin, never
// the URL-supplied one. Reauth itself is pinned to the
// configured GUI origin (see triggerReauth).
this.setAPIOrigin(this.defaultAPIOrigin);
this.triggerReauth(reauthSignal);
}
}
// SDK was loaded in a 3rd-party website.
// When SDK is loaded in GUI the initiation process should start when the DOM is ready. This is because
@@ -742,8 +782,15 @@ const puterInit = function () {
STORAGE_KEY_V2,
normalizedAuthToken,
);
// Persist the origin this token is bound to alongside
// it, so a later boot only reuses it for that origin.
localStorage.setItem(
STORAGE_KEY_ORIGIN_V2,
this.APIOrigin,
);
} else {
localStorage.removeItem(STORAGE_KEY_V2);
localStorage.removeItem(STORAGE_KEY_ORIGIN_V2);
}
// Always clear the legacy v1 key on a write — once we
// have a v2 token, the v1 one must not linger or it
@@ -771,6 +818,22 @@ const puterInit = function () {
});
};
/**
* Decides whether a stored token may be attached to requests for the
* current API origin.
* - A bound token may only ever be replayed to the exact origin it
* was minted against.
* - An unbound (legacy) token is only honored against the default API
* origin — never against a URL-supplied custom `puter.api_origin`.
*/
_storedTokenUsableForCurrentOrigin = function (boundOrigin) {
return isStoredTokenUsableForOrigin({
boundOrigin,
currentOrigin: this.APIOrigin,
defaultAPIOrigin: this.defaultAPIOrigin,
});
};
setAPIOrigin = function (APIOrigin) {
this.APIOrigin = APIOrigin;
// reinitialize submodules
@@ -810,6 +873,7 @@ const puterInit = function () {
if (this.env === 'web' || this.env === 'app') {
try {
localStorage.removeItem(STORAGE_KEY_V2);
localStorage.removeItem(STORAGE_KEY_ORIGIN_V2);
localStorage.removeItem(STORAGE_KEY_V1);
} catch (error) {
// Handle the error here
@@ -852,6 +916,7 @@ const puterInit = function () {
if (this.env === 'web' || this.env === 'app') {
try {
localStorage.removeItem(STORAGE_KEY_V2);
localStorage.removeItem(STORAGE_KEY_ORIGIN_V2);
localStorage.removeItem(STORAGE_KEY_V1);
} catch (e) {
console.error('Error accessing localStorage:', e);
@@ -883,15 +948,6 @@ const puterInit = function () {
return;
}
if (this.env === 'app') {
// We're inside an iframe in the GUI. Ask the parent to
// surface the reauth modal; once the user signs in there,
// the existing `puter.token` postMessage delivers the
// fresh token back to us.
//
// targetOrigin is locked to the expected GUI origin —
// postMessage('*') would leak reauth signal + auth_id
// to any embedding parent (including a malicious one),
// and the message body is only meaningful to the GUI.
try {
globalThis.parent?.postMessage?.(
{
+31
View File
@@ -0,0 +1,31 @@
/**
* Decides whether a stored auth token may be attached to requests destined
* for a given API origin.
*
* In `app` mode the API origin is taken from a URL-controlled
* `puter.api_origin` parameter, so a stored token must never be replayed to
* an arbitrary origin. The rules are:
*
* - A token that was stored with an explicit origin binding may only ever
* be replayed to that exact origin.
* - A token with no binding (a legacy token stored before origin binding
* existed) is only honored against the default API origin — never against
* a URL-supplied custom origin.
*
* @param {object} params
* @param {string|null|undefined} params.boundOrigin - Origin the stored token
* was minted against, or null/undefined for an unbound (legacy) token.
* @param {string} params.currentOrigin - The API origin the token would be
* used against right now.
* @param {string} params.defaultAPIOrigin - The SDK's default (trusted) API
* origin for this deployment.
* @returns {boolean} True if the token may be used for `currentOrigin`.
*/
export const isStoredTokenUsableForOrigin = ({
boundOrigin,
currentOrigin,
defaultAPIOrigin,
}) => {
if (boundOrigin) return boundOrigin === currentOrigin;
return currentOrigin === defaultAPIOrigin;
};
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { isStoredTokenUsableForOrigin } from './authTokenOrigin.js';
const DEFAULT = 'https://api.puter.com';
const ATTACKER = 'https://attacker.example';
describe('isStoredTokenUsableForOrigin', () => {
it('allows a bound token only against its own origin', () => {
expect(
isStoredTokenUsableForOrigin({
boundOrigin: DEFAULT,
currentOrigin: DEFAULT,
defaultAPIOrigin: DEFAULT,
}),
).toBe(true);
});
it('rejects a bound token against a different (URL-supplied) origin', () => {
// The core exploit: a token minted for the real API must not be
// replayed to an attacker-controlled origin.
expect(
isStoredTokenUsableForOrigin({
boundOrigin: DEFAULT,
currentOrigin: ATTACKER,
defaultAPIOrigin: DEFAULT,
}),
).toBe(false);
});
it('honors an unbound (legacy) token against the default origin', () => {
expect(
isStoredTokenUsableForOrigin({
boundOrigin: null,
currentOrigin: DEFAULT,
defaultAPIOrigin: DEFAULT,
}),
).toBe(true);
});
it('rejects an unbound (legacy) token against a custom origin', () => {
// Even without a binding, a legacy token can never be sent to a
// custom URL-supplied origin.
expect(
isStoredTokenUsableForOrigin({
boundOrigin: undefined,
currentOrigin: ATTACKER,
defaultAPIOrigin: DEFAULT,
}),
).toBe(false);
});
it('binds to a self-hosted default origin', () => {
// A self-hosted deployment sets its own default API origin; a token
// bound to it is usable there, and a legacy token is allowed there.
const selfHosted = 'https://api.my-puter.example';
expect(
isStoredTokenUsableForOrigin({
boundOrigin: selfHosted,
currentOrigin: selfHosted,
defaultAPIOrigin: selfHosted,
}),
).toBe(true);
expect(
isStoredTokenUsableForOrigin({
boundOrigin: null,
currentOrigin: selfHosted,
defaultAPIOrigin: selfHosted,
}),
).toBe(true);
// ...but that legacy self-hosted token still can't be redirected.
expect(
isStoredTokenUsableForOrigin({
boundOrigin: null,
currentOrigin: ATTACKER,
defaultAPIOrigin: selfHosted,
}),
).toBe(false);
});
});
+33 -2
View File
@@ -48,8 +48,9 @@ class Auth {
options = options || {};
return new Promise((resolve, reject) => {
const signinsession = crypto.randomUUID();
const msg_id = this.#messageID++;
const url = `${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}`;
const url = `${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? `&cross_origin_isolated=true&signin_session=${signinsession}` : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}`;
// Guards against settling the promise more than once across the
// message, popup-closed, and dialog-cancel code paths.
@@ -68,6 +69,32 @@ class Auth {
window.removeEventListener('message', messageHandler);
};
if ( window.crossOriginIsolated ) {
(async () => {
while (true) {
try {
const result = await fetch(`${this.APIOrigin}/login/wait`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ session: signinsession }),
});
if ( result.ok ) {
const { auth_token } = await result.json();
if (settled) return;
settled = true;
cleanup();
puter.setAuthToken(auth_token);
resolve({ success: true, token: auth_token });
return '';
}
} catch {}
await new Promise(r => setTimeout(r, 1000));
}
})();
}
function messageHandler (e) {
// Only accept the token from the Puter GUI origin AND from the
// popup window we opened. Origin alone is insufficient (any
@@ -137,7 +164,11 @@ class Auth {
if ( hasUserActivation() ) {
// A user gesture is active — open the popup immediately.
watchPopup(openAuthPopup(url));
const popup = openAuthPopup(url);
if ( !window.crossOriginIsolated ) {
// cannot watch in isolated mode
watchPopup(popup);
}
} else {
// No user gesture: a popup opened now would be blocked by the
// browser. Show a consent dialog first; the popup is then
+80
View File
@@ -0,0 +1,80 @@
import * as utils from '../lib/utils.js';
/**
* Restricted outbound email (the `puter-email` driver interface).
*
* Every send must be authorized by a worker: either the worker calls
* directly (`me.puter.email.send(...)`), or a user calls with their own
* token and passes the worker's token as `emailAccessToken` — the caller is
* the one billed and rate-limited. In a worker handler:
*
* router.post('/notify', async ({ request, user }) => {
* const { to, subject, text } = await request.json();
* return await user.puter.email.send({
* to, subject, text,
* emailAccessToken: me.puter.authToken,
* // Inline or Puter-FS attachments:
* attachments: [
* { filename, content, contentType }, // content = base64
* { path: '~/Documents/report.pdf' }, // read server-side
* ],
* });
* });
*
* Positional form: `await puter.email.send(to, subject, body)`.
*/
class Email {
/**
* @class
* @param {object} puter - The parent puter instance.
*/
constructor(puter) {
this.puter = puter;
this.authToken = puter.authToken;
this.APIOrigin = puter.APIOrigin;
this.appID = puter.appID;
}
/**
* Sets a new authentication token.
*
* @param {string} authToken - The new authentication token.
* @returns {void}
*/
setAuthToken(authToken) {
this.authToken = authToken;
}
/**
* Sets the API origin.
*
* @param {string} APIOrigin - The new API origin.
* @returns {void}
*/
setAPIOrigin(APIOrigin) {
this.APIOrigin = APIOrigin;
}
send = utils.make_driver_method(
['to', 'subject', 'body'],
'puter-email',
undefined,
'send',
{
preprocess: (args) => {
// `body` is positional-call sugar for `text`.
if (
args.body !== undefined &&
args.text === undefined &&
args.html === undefined
) {
args.text = args.body;
}
delete args.body;
return args;
},
},
);
}
export default Email;
+1 -1
View File
@@ -220,7 +220,7 @@ class UsageLimitDialog extends (globalThis.HTMLElement || Object) {
});
this.shadowRoot.querySelector('#upgrade-btn').addEventListener('click', () => {
window.open('https://puter.com/dashboard', '_blank');
window.open('https://puter.com/dashboard/#home', '_blank');
this.close();
});
+53
View File
@@ -0,0 +1,53 @@
/**
* One attachment: either inline base64 `content`, or a Puter FS reference
* (`path`/`uid`) read server-side with the caller's — falling back to the
* authorizing worker's — file permissions.
*/
export interface EmailAttachment {
/** Required with `content`; defaults to the file's name for FS refs. */
filename?: string;
/** Base64 file body. Mutually exclusive with `path`/`uid`. */
content?: string;
/** Puter FS path (supports `~/`). Mutually exclusive with `content`. */
path?: string;
/** Puter FS entry uid. Mutually exclusive with `content`. */
uid?: string;
contentType?: string;
}
export interface EmailSendOptions {
/** Recipient address(es). */
to: string | string[];
subject: string;
/** Plain-text body. At least one of `text` / `html` is required. */
text?: string;
/** HTML body. */
html?: string;
cc?: string | string[];
bcc?: string | string[];
replyTo?: string;
/**
* A worker's auth token authorizing the send when the caller is not
* itself a worker (inside a worker: `me.puter.authToken`). The caller
* stays the billed and rate-limited identity.
*/
emailAccessToken?: string;
attachments?: EmailAttachment[];
}
export interface EmailSendResult {
/** Transport message id, when the mail server reports one. */
messageId: string | null;
/** Total charge for this send, in microcents. */
cost: number;
}
/**
* Restricted outbound email. Sending is limited server-side to trusted
* callers (Puter workers owned by allowlisted or permitted users).
*/
export class Email {
/** Sends an email with a plain-text body. */
send (to: string | string[], subject: string, body: string): Promise<EmailSendResult>;
send (options: EmailSendOptions): Promise<EmailSendResult>;
}
+2
View File
@@ -3,6 +3,7 @@ import type { Apps } from './modules/apps.d.ts';
import type { Auth } from './modules/auth.d.ts';
import type { Debug } from './modules/debug.d.ts';
import type { Drivers } from './modules/drivers.d.ts';
import type { Email } from './modules/email.d.ts';
import type { FS } from './modules/filesystem.d.ts';
import type { FSItem } from './modules/fs-item.d.ts';
import type { Hosting } from './modules/hosting.d.ts';
@@ -53,6 +54,7 @@ export class Puter {
ui: UI;
hosting: Hosting;
kv: KV;
email: Email;
perms: Perms;
drivers: Drivers;
debug: Debug;