From 47b133d512c7a0fe8648bbf1347c81e84c658677 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:54:16 -0500 Subject: [PATCH 01/39] dev(backend): incomplete oauth2 OIDC impl --- src/backend/src/CoreModule.js | 3 + src/backend/src/config.js | 6 + .../src/modules/web/WebServerService.js | 2 +- src/backend/src/routers/auth/oidc.js | 141 ++++ src/backend/src/services/PuterAPIService.js | 1 + src/backend/src/services/auth/OIDCService.js | 291 ++++++++ .../database/SqliteDatabaseAccessService.js | 3 + .../sqlite_setup/0045_user_oidc_providers.sql | 16 + .../repositories/DBKVStore/DBKVStore.js | 283 ++++++++ .../src/services/repositories/DDBClient.js | 196 ++++++ .../services/repositories/DDBClientWrapper.js | 18 + .../DynamoKVStore/DynamoKVStore.js | 647 ++++++++++++++++++ .../DynamoKVStore/DynamoKVStoreWrapper.js | 55 ++ .../DynamoKVStore/tableDefinition.js | 24 + src/gui/src/UI/UIWindowLogin.js | 37 +- src/gui/src/UI/UIWindowSignup.js | 27 +- 16 files changed, 1738 insertions(+), 12 deletions(-) create mode 100644 src/backend/src/routers/auth/oidc.js create mode 100644 src/backend/src/services/auth/OIDCService.js create mode 100644 src/backend/src/services/database/sqlite_setup/0045_user_oidc_providers.sql create mode 100644 src/backend/src/services/repositories/DBKVStore/DBKVStore.js create mode 100644 src/backend/src/services/repositories/DDBClient.js create mode 100644 src/backend/src/services/repositories/DDBClientWrapper.js create mode 100644 src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.js create mode 100644 src/backend/src/services/repositories/DynamoKVStore/DynamoKVStoreWrapper.js create mode 100644 src/backend/src/services/repositories/DynamoKVStore/tableDefinition.js diff --git a/src/backend/src/CoreModule.js b/src/backend/src/CoreModule.js index 9ddd90724..36ebe6434 100644 --- a/src/backend/src/CoreModule.js +++ b/src/backend/src/CoreModule.js @@ -269,6 +269,9 @@ const install = async ({ context, services, app, useapi, modapi }) => { const { OTPService } = require('./services/auth/OTPService'); services.registerService('otp', OTPService); + const { OIDCService } = require('./services/auth/OIDCService'); + services.registerService('oidc', OIDCService); + const { UserProtectedEndpointsService } = require('./services/web/UserProtectedEndpointsService'); services.registerService('__user-protected-endpoints', UserProtectedEndpointsService); diff --git a/src/backend/src/config.js b/src/backend/src/config.js index 95d988a28..1d3a5673e 100644 --- a/src/backend/src/config.js +++ b/src/backend/src/config.js @@ -59,6 +59,12 @@ config.captcha = { difficulty: 'medium', // Default difficulty level }; +// OIDC/OAuth2 providers (e.g. Google). Keys in config only, not env vars. +// Example: config.oidc.providers.google = { client_id, client_secret } +config.oidc = { + providers: {}, +}; + config.monitor = { metricsInterval: 60000, windowSize: 30, diff --git a/src/backend/src/modules/web/WebServerService.js b/src/backend/src/modules/web/WebServerService.js index e1219e8c9..1814b4412 100644 --- a/src/backend/src/modules/web/WebServerService.js +++ b/src/backend/src/modules/web/WebServerService.js @@ -627,7 +627,7 @@ class WebServerService extends BaseService { req.co_isolation_enabled ; - if ( req.path === '/signup' || req.path === '/login' || req.path.startsWith('/extensions/') ) { + if ( req.path === '/signup' || req.path === '/login' || req.path.startsWith('/extensions/') || req.path.startsWith('/auth/oidc') ) { res.setHeader('Access-Control-Allow-Origin', origin ?? '*'); } // Website(s) to allow to connect diff --git a/src/backend/src/routers/auth/oidc.js b/src/backend/src/routers/auth/oidc.js new file mode 100644 index 000000000..ac54c2e90 --- /dev/null +++ b/src/backend/src/routers/auth/oidc.js @@ -0,0 +1,141 @@ +/* + * 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 . + */ +'use strict'; +const express = require('express'); +const router = new express.Router(); +const config = require('../../config'); + +const complete_ = async ({ req, res, user }) => { + const svc_auth = req.services.get('auth'); + const { token } = await svc_auth.create_session_token(user, { req }); + res.cookie(config.cookie_name, token, { + sameSite: 'none', + secure: true, + httpOnly: true, + }); + return res.send({ + proceed: true, + next_step: 'complete', + token, + user: { + username: user.username, + uuid: user.uuid, + email: user.email, + email_confirmed: user.email_confirmed, + is_temp: (user.password === null && user.email === null), + }, + }); +}; + +// GET /auth/oidc/providers - list enabled provider ids for frontend +router.get('/auth/oidc/providers', async (req, res) => { + if ( require('../../helpers').subdomain(req) !== 'api' && require('../../helpers').subdomain(req) !== '' ) { + return res.status(404).end(); + } + const svc_oidc = req.services.get('oidc'); + const providers = await svc_oidc.getEnabledProviderIds(); + return res.json({ providers }); +}); + +// GET /auth/oidc/:provider/start - redirect to IdP authorization +router.get('/auth/oidc/:provider/start', async (req, res) => { + if ( require('../../helpers').subdomain(req) !== 'api' && require('../../helpers').subdomain(req) !== '' ) { + return res.status(404).end(); + } + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('login') ) { + return res.status(429).send('Too many requests.'); + } + const provider = req.params.provider; + const svc_oidc = req.services.get('oidc'); + const cfg = await svc_oidc.getProviderConfig(provider); + if ( ! cfg ) { + return res.status(404).send('Provider not configured.'); + } + const redirectUri = req.query.redirect_uri ? String(req.query.redirect_uri) : undefined; + const statePayload = { provider, redirect_uri: redirectUri }; + const state = svc_oidc.signState(statePayload); + const url = await svc_oidc.getAuthorizationUrl(provider, state, redirectUri ? undefined : undefined); + if ( ! url ) { + return res.status(502).send('Could not build authorization URL.'); + } + return res.redirect(302, url); +}); + +// GET /auth/oidc/callback - handle IdP redirect (code + state) +router.get('/auth/oidc/callback', async (req, res) => { + if ( require('../../helpers').subdomain(req) !== 'api' && require('../../helpers').subdomain(req) !== '' ) { + return res.status(404).end(); + } + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('login') ) { + return res.status(429).send('Too many requests.'); + } + const code = req.query.code; + const state = req.query.state; + if ( !code || !state ) { + return res.status(400).send('Missing code or state.'); + } + const svc_oidc = req.services.get('oidc'); + const stateDecoded = svc_oidc.verifyState(state); + if ( !stateDecoded || !stateDecoded.provider ) { + return res.status(400).send('Invalid or expired state.'); + } + const provider = stateDecoded.provider; + const redirectUri = `${config.api_base_url}/auth/oidc/callback`; + const tokens = await svc_oidc.exchangeCodeForTokens(provider, code, redirectUri); + if ( !tokens || !tokens.access_token ) { + return res.status(401).send('Token exchange failed.'); + } + const userinfo = await svc_oidc.getUserInfo(provider, tokens.access_token); + if ( !userinfo || !userinfo.sub ) { + return res.status(401).send('Could not get user info.'); + } + let user = await svc_oidc.findUserByProviderSub(provider, userinfo.sub); + if ( user ) { + if ( user.suspended ) { + return res.status(401).send('This account is suspended.'); + } + return await complete_({ req, res, user }); + } + user = await svc_oidc.createUserFromOIDC(provider, userinfo); + if ( ! user ) { + return res.status(400).send('Email already registered. Please log in with your password and link your Google account, or use a different email.'); + } + const accept = req.headers.accept || ''; + const wantsRedirect = accept.includes('text/html'); + if ( wantsRedirect ) { + const svc_auth = req.services.get('auth'); + const { token } = await svc_auth.create_session_token(user, { req }); + res.cookie(config.cookie_name, token, { + sameSite: 'none', + secure: true, + httpOnly: true, + }); + let target = stateDecoded.redirect_uri || config.origin || '/'; + const origin = config.origin || ''; + if ( target && origin && !target.startsWith(origin) ) { + target = origin; + } + return res.redirect(302, target); + } + return await complete_({ req, res, user }); +}); + +module.exports = router; diff --git a/src/backend/src/services/PuterAPIService.js b/src/backend/src/services/PuterAPIService.js index 612a40a55..a3e82406f 100644 --- a/src/backend/src/services/PuterAPIService.js +++ b/src/backend/src/services/PuterAPIService.js @@ -70,6 +70,7 @@ class PuterAPIService extends BaseService { // app.use(require('../routers/get-launch-apps')) app.use(require('../routers/itemMetadata')); app.use(require('../routers/login')); + app.use(require('../routers/auth/oidc')); app.use(require('../routers/logout')); app.use(require('../routers/open_item')); app.use(require('../routers/passwd')); diff --git a/src/backend/src/services/auth/OIDCService.js b/src/backend/src/services/auth/OIDCService.js new file mode 100644 index 000000000..e1029a429 --- /dev/null +++ b/src/backend/src/services/auth/OIDCService.js @@ -0,0 +1,291 @@ +/* + * 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 . + */ +'use strict'; +const BaseService = require('../BaseService'); +const { DB_WRITE } = require('../database/consts'); +const { generate_identifier } = require('../../util/identifier'); + +const GOOGLE_DISCOVERY_URL = 'https://accounts.google.com/.well-known/openid-configuration'; +const GOOGLE_SCOPES = 'openid email profile'; +const STATE_EXPIRY_SEC = 600; // 10 minutes + +/** + * OIDC/OAuth2 service for sign-in with Google (and extensible to other providers). + * Uses config.oidc.providers only; no environment variables. + */ +class OIDCService extends BaseService { + static MODULES = { + jwt: require('jsonwebtoken'), + }; + + async _init () { + this.db = await this.services.get('database').get(DB_WRITE, 'auth'); + this.providers = this.config.providers ?? {}; + this._googleDiscovery = null; + } + + /** + * Get provider config from config.oidc.providers. For Google, resolve endpoints from discovery. + * @param {string} providerId - e.g. 'google' + * @returns {Promise} Config with client_id, client_secret, authorization_endpoint, token_endpoint, userinfo_endpoint, scopes + */ + async getProviderConfig (providerId) { + const providers = this.providers; + const raw = providers[providerId]; + if ( !raw || typeof raw !== 'object' || !raw.client_id || !raw.client_secret ) { + return null; + } + if ( providerId === 'google' ) { + const discovery = await this._getGoogleDiscovery_(); + if ( ! discovery ) return null; + return { + client_id: raw.client_id, + client_secret: raw.client_secret, + authorization_endpoint: discovery.authorization_endpoint, + token_endpoint: discovery.token_endpoint, + userinfo_endpoint: discovery.userinfo_endpoint, + scopes: raw.scopes ?? GOOGLE_SCOPES, + }; + } + if ( raw.authorization_endpoint && raw.token_endpoint && raw.userinfo_endpoint ) { + return { + ...raw, + scopes: raw.scopes ?? 'openid email profile', + }; + } + return null; + } + + async _getGoogleDiscovery_ () { + if ( this._googleDiscovery ) return this._googleDiscovery; + try { + const res = await fetch(GOOGLE_DISCOVERY_URL); + if ( ! res.ok ) return null; + this._googleDiscovery = await res.json(); + return this._googleDiscovery; + } catch ( e ) { + this.log?.warn?.('OIDC: Google discovery fetch failed', e); + return null; + } + } + + /** + * Build authorization URL for the provider. redirect_uri is our callback URL. + */ + async getAuthorizationUrl (providerId, state, redirectUri) { + const config = await this.getProviderConfig(providerId); + if ( ! config ) return null; + const base = redirectUri ?? `${this.global_config.api_base_url}/auth/oidc/callback`; + const params = new URLSearchParams({ + client_id: config.client_id, + redirect_uri: base, + response_type: 'code', + scope: config.scopes, + state, + }); + return `${config.authorization_endpoint}?${params.toString()}`; + } + + /** + * Sign state payload for CSRF protection (short-lived JWT). + */ + signState (payload) { + return this.modules.jwt.sign(payload, + this.global_config.jwt_secret, + { expiresIn: STATE_EXPIRY_SEC }); + } + + verifyState (token) { + try { + return this.modules.jwt.verify(token, this.global_config.jwt_secret); + } catch ( e ) { + return null; + } + } + + /** + * Exchange authorization code for tokens. + */ + async exchangeCodeForTokens (providerId, code, redirectUri) { + const config = await this.getProviderConfig(providerId); + if ( ! config ) return null; + const base = redirectUri ?? `${this.global_config.api_base_url}/auth/oidc/callback`; + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: base, + client_id: config.client_id, + client_secret: config.client_secret, + }); + const res = await fetch(config.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + if ( ! res.ok ) { + const text = await res.text(); + this.log?.warn?.('OIDC token exchange failed', { status: res.status, body: text }); + return null; + } + return await res.json(); + } + + /** + * Get userinfo from provider (e.g. Google userinfo endpoint). + */ + async getUserInfo (providerId, accessToken) { + const config = await this.getProviderConfig(providerId); + if ( !config || !config.userinfo_endpoint ) return null; + const res = await fetch(config.userinfo_endpoint, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if ( ! res.ok ) return null; + return await res.json(); + } + + /** + * Find Puter user by provider and IdP subject. Returns user object or null. + */ + async findUserByProviderSub (providerId, providerSub) { + const rows = await this.db.pread('SELECT user_id FROM user_oidc_providers WHERE provider = ? AND provider_sub = ? LIMIT 1', + [providerId, providerSub]); + if ( !rows || rows.length === 0 ) return null; + const svc_get_user = this.services.get('get-user'); + return await svc_get_user.get_user({ id: rows[0].user_id, cached: false }); + } + + /** + * Link an existing Puter user to an OIDC provider identity. + */ + async linkProviderToUser (userId, providerId, providerSub, refreshToken = null) { + try { + await this.db.write('INSERT INTO user_oidc_providers (user_id, provider, provider_sub, refresh_token) VALUES (?, ?, ?, ?)', + [userId, providerId, providerSub, refreshToken]); + } catch ( e ) { + if ( e.message?.includes('UNIQUE') || e.code === 'SQLITE_CONSTRAINT' ) { + // already linked + return; + } + throw e; + } + } + + /** + * Create a new Puter user from OIDC claims and link the provider. Reuses signup patterns (groups, default fs). + */ + async createUserFromOIDC (providerId, claims) { + const db = this.db; + const svc_group = this.services.get('group'); + const svc_user = this.services.get('user'); + const { v4: uuidv4 } = require('uuid'); + + let username = (claims.name || claims.email || '').toString().trim(); + if ( username ) { + username = username.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, ''); + if ( username.length > 45 ) username = username.slice(0, 45); + } + if ( !username || !/^\w+$/.test(username) ) { + let candidate; + do { + candidate = generate_identifier(); + const [r] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [candidate]); + if ( ! r ) username = candidate; + } while ( !username ); + } else { + const [existing] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [username]); + if ( existing ) { + let suffix = 1; + while ( true ) { + const candidate = `${username}${suffix}`; + const [r] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [candidate]); + if ( ! r ) { + username = candidate; break; + } + suffix++; + } + } + } + + const email = (claims.email || '').toString().trim() || null; + const clean_email = email ? email.toLowerCase().trim() : null; + if ( clean_email ) { + const [existingEmail] = await db.pread('SELECT 1 FROM user WHERE clean_email = ? LIMIT 1', [clean_email]); + if ( existingEmail ) { + return null; // email already registered; caller should return error + } + } + const user_uuid = uuidv4(); + const email_confirm_code = String(Math.floor(100000 + Math.random() * 900000)); + const email_confirm_token = uuidv4(); + + await db.write(`INSERT INTO user ( + username, email, clean_email, password, uuid, referrer, + email_confirm_code, email_confirm_token, free_storage, + referred_by, email_confirmed, requires_email_confirmation + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + username, + email, + clean_email, + null, + user_uuid, + null, + email_confirm_code, + email_confirm_token, + this.global_config.storage_capacity, + null, + 1, + 0, + ]); + const [inserted] = await db.pread('SELECT id FROM user WHERE uuid = ? LIMIT 1', [user_uuid]); + const user_id = inserted.id; + + await this.linkProviderToUser(user_id, providerId, claims.sub, null); + + await svc_group.add_users({ + uid: this.global_config.default_user_group, + users: [username], + }); + + const [user] = await db.pread('SELECT * FROM user WHERE id = ? LIMIT 1', [user_id]); + if ( user && user.metadata && typeof user.metadata === 'string' ) { + user.metadata = JSON.parse(user.metadata); + } else if ( user && !user.metadata ) { + user.metadata = {}; + } + await svc_user.generate_default_fsentries({ user }); + + return user; + } + + /** + * List provider ids that have valid config (for frontend to show "Sign in with Google" etc.). + */ + async getEnabledProviderIds () { + const providers = this.providers ?? {}; + const ids = []; + for ( const id of Object.keys(providers) ) { + const cfg = await this.getProviderConfig(id); + if ( cfg ) ids.push(id); + } + return ids; + } +} + +module.exports = { OIDCService }; diff --git a/src/backend/src/services/database/SqliteDatabaseAccessService.js b/src/backend/src/services/database/SqliteDatabaseAccessService.js index 7fb9ff636..7f10d7278 100644 --- a/src/backend/src/services/database/SqliteDatabaseAccessService.js +++ b/src/backend/src/services/database/SqliteDatabaseAccessService.js @@ -170,6 +170,9 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { [40, [ '0044_dev-center-godmode.sql', ]], + [41, [ + '0045_user_oidc_providers.sql', + ]], ]; // Database upgrade logic diff --git a/src/backend/src/services/database/sqlite_setup/0045_user_oidc_providers.sql b/src/backend/src/services/database/sqlite_setup/0045_user_oidc_providers.sql new file mode 100644 index 000000000..b8cc6d8a6 --- /dev/null +++ b/src/backend/src/services/database/sqlite_setup/0045_user_oidc_providers.sql @@ -0,0 +1,16 @@ +-- OIDC/OAuth2: link user accounts to identity providers (e.g. Google) +-- Used for "Sign in with Google" login and signup + +CREATE TABLE `user_oidc_providers` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `user_id` INTEGER NOT NULL, + `provider` VARCHAR(64) NOT NULL, + `provider_sub` VARCHAR(255) NOT NULL, + `refresh_token` TEXT DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(`provider`, `provider_sub`), + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +); + +CREATE INDEX `idx_user_oidc_providers_provider_sub` ON `user_oidc_providers` (`provider`, `provider_sub`); +CREATE INDEX `idx_user_oidc_providers_user_id` ON `user_oidc_providers` (`user_id`); diff --git a/src/backend/src/services/repositories/DBKVStore/DBKVStore.js b/src/backend/src/services/repositories/DBKVStore/DBKVStore.js new file mode 100644 index 000000000..442dc3c5f --- /dev/null +++ b/src/backend/src/services/repositories/DBKVStore/DBKVStore.js @@ -0,0 +1,283 @@ +import murmurhash from 'murmurhash'; +import APIError from '../../../api/APIError.js'; +import { Context } from '../../../util/context.js'; +const GLOBAL_APP_KEY = 'global'; +export class DBKVStore { + #db; + #meteringService; + #global_config = {}; + constructor ({ sqlClient, meteringService, globalConfig }) { + this.#db = sqlClient; + this.#meteringService = meteringService; + this.#global_config = globalConfig; + } + async get ({ key }) { + const actor = Context.get('actor'); + const app = actor.type?.app ?? undefined; + const user = actor.type?.user ?? undefined; + if ( ! user ) { + throw new Error('User not found'); + } + const deleteExpired = async (rows) => { + const query = `DELETE FROM kv WHERE user_id=? AND app=? AND kkey_hash IN (${rows.map(() => '?').join(',')})`; + const params = [user.id, app?.uid ?? GLOBAL_APP_KEY, ...rows.map((r) => r.kkey_hash)]; + return await this.#db.write(query, params); + }; + if ( Array.isArray(key) ) { + const keys = key; + const key_hashes = keys.map((key) => murmurhash.v3(key)); + const placeholders = key_hashes.map(() => '?').join(','); + const params = app + ? [user.id, app.uid, ...key_hashes] + : [user.id, ...key_hashes]; + const rows = app + ? await this.#db.read(`SELECT kkey, value, expireAt FROM kv WHERE user_id=? AND app=? AND kkey_hash IN (${placeholders})`, params) + : await this.#db.read(`SELECT kkey, value, expireAt FROM kv WHERE user_id=? AND (app IS NULL OR app = '${GLOBAL_APP_KEY}') AND kkey_hash IN (${placeholders})`, params); + const kvPairs = {}; + rows.forEach((row) => { + row.value = this.#db.case({ + mysql: () => row.value, + otherwise: () => JSON.parse(row.value ?? 'null'), + })(); + kvPairs[row.kkey] = row.value; + }); + const expiredKeys = []; + rows.forEach((row) => { + if ( row?.expireAt && row.expireAt < Date.now() / 1000 ) { + expiredKeys.push(row); + kvPairs[row.kkey] = null; + } + else { + kvPairs[row.kkey] = row.value ?? null; + } + }); + if ( expiredKeys.length ) { + deleteExpired(expiredKeys); + } + return keys.map((key) => Object.prototype.hasOwnProperty.call(kvPairs, key) ? kvPairs[key] : null); + } + const key_hash = murmurhash.v3(key); + const kv = app + ? await this.#db.read('SELECT * FROM kv WHERE user_id=? AND app=? AND kkey_hash=? LIMIT 1', [user.id, app.uid, key_hash]) + : await this.#db.read(`SELECT * FROM kv WHERE user_id=? AND (app IS NULL OR app = '${GLOBAL_APP_KEY}') AND kkey_hash=? LIMIT 1`, [user.id, key_hash]); + if ( kv[0] ) { + kv[0].value = this.#db.case({ + mysql: () => kv[0].value, + otherwise: () => JSON.parse(kv[0].value ?? 'null'), + })(); + } + if ( kv[0]?.expireAt && kv[0].expireAt < Date.now() / 1000 ) { + deleteExpired([kv[0]]); + return null; + } + await this.#meteringService.incrementUsage(actor, 'kv:read', Array.isArray(key) ? key.length : 1); + return kv[0]?.value ?? null; + } + async set ({ key, value, expireAt }) { + const actor = Context.get('actor'); + const config = this.#global_config; + key = String(key); + if ( Buffer.byteLength(key, 'utf8') > config.kv_max_key_size ) { + throw new Error(`key is too large. Max size is ${config.kv_max_key_size}.`); + } + if ( value !== null && + Buffer.byteLength(JSON.stringify(value), 'utf8') > config.kv_max_value_size ) { + throw new Error(`value is too large. Max size is ${config.kv_max_value_size}.`); + } + const app = actor.type?.app ?? undefined; + const user = actor.type?.user ?? undefined; + if ( ! user ) { + throw new Error('User not found'); + } + const key_hash = murmurhash.v3(key); + try { + await this.#db.write(`INSERT INTO kv (user_id, app, kkey_hash, kkey, value, expireAt) + VALUES (?, ?, ?, ?, ?, ?) ${this.#db.case({ + mysql: 'ON DUPLICATE KEY UPDATE value = ?', + sqlite: 'ON CONFLICT(user_id, app, kkey_hash) DO UPDATE SET value = excluded.value', + })}`, [ + user.id, + app?.uid ?? GLOBAL_APP_KEY, + key_hash, + key, + JSON.stringify(value), + expireAt ?? undefined, + ...this.#db.case({ mysql: [value], otherwise: [] }), + ]); + } + catch (e) { + console.error(e); + } + await this.#meteringService.incrementUsage(actor, 'kv:write', 1); + return true; + } + async del ({ key }) { + const actor = Context.get('actor'); + const app = actor.type?.app ?? undefined; + const user = actor.type?.user ?? undefined; + if ( ! user ) { + throw new Error('User not found'); + } + const key_hash = murmurhash.v3(key); + await this.#db.write('DELETE FROM kv WHERE user_id=? AND app=? AND kkey_hash=?', [ + user.id, + app?.uid ?? GLOBAL_APP_KEY, + key_hash, + ]); + await this.#meteringService.incrementUsage(actor, 'kv:write', 1); + return true; + } + async list ({ as }) { + const actor = Context.get('actor'); + const app = actor.type?.app ?? undefined; + const user = actor.type?.user ?? undefined; + if ( ! user ) { + throw new Error('User not found'); + } + let rows = app + ? await this.#db.read('SELECT kkey, value, expireAt FROM kv WHERE user_id=? AND app=?', [user.id, app.uid]) + : await this.#db.read(`SELECT kkey, value, expireAt FROM kv WHERE user_id=? AND (app IS NULL OR app = '${GLOBAL_APP_KEY}')`, [user.id]); + rows = rows.filter((row) => { + return !row?.expireAt || row?.expireAt > Date.now() / 1000; + }); + rows = rows.map((row) => ({ + key: row.kkey, + value: this.#db.case({ + mysql: () => row.value, + otherwise: () => JSON.parse(row.value ?? 'null'), + })(), + })); + as = as || 'entries'; + if ( ! ['keys', 'values', 'entries'].includes(as) ) { + throw APIError.create('field_invalid', undefined, { + key: 'as', + expected: '"keys", "values", or "entries"', + }); + } + if ( as === 'keys' ) { + rows = rows.map((row) => row.key); + } + else if ( as === 'values' ) { + rows = rows.map((row) => row.value); + } + await this.#meteringService.incrementUsage(actor, 'kv:read', rows.length); + return rows; + } + async flush () { + const actor = Context.get('actor'); + const app = actor.type?.app ?? undefined; + const user = actor.type?.user ?? undefined; + if ( ! user ) { + throw new Error('User not found'); + } + await this.#db.write('DELETE FROM kv WHERE user_id=? AND app=?', [ + user.id, + app?.uid ?? GLOBAL_APP_KEY, + ]); + await this.#meteringService.incrementUsage(actor, 'kv:write', 1); + return true; + } + async expireAt ({ key, timestamp }) { + if ( key === '' ) { + throw APIError.create('field_empty', undefined, { + key: 'key', + }); + } + timestamp = Number(timestamp); + return await this.#expireat(key, timestamp); + } + async expire ({ key, ttl }) { + if ( key === '' ) { + throw APIError.create('field_empty', undefined, { + key: 'key', + }); + } + ttl = Number(ttl); + let timestamp = Math.floor(Date.now() / 1000) + ttl; + return await this.#expireat(key, timestamp); + } + async incr ({ key, pathAndAmountMap }) { + if ( Object.values(pathAndAmountMap).find((v) => typeof v !== 'number') ) { + throw new Error('All values in pathAndAmountMap must be numbers'); + } + let currVal = await this.get({ key }); + const pathEntries = Object.entries(pathAndAmountMap); + if ( typeof currVal !== 'object' && pathEntries.length <= 1 && !pathEntries[0]?.[0] ) { + const amount = pathEntries[0]?.[1] ?? 1; + this.set({ key, value: (Number(currVal) || 0) + amount }); + return ((Number(currVal) || 0) + amount); + } + if ( Array.isArray(currVal) ) { + throw new Error('Current value is an array'); + } + if ( ! currVal ) { + currVal = {}; + } + if ( typeof currVal !== 'object' ) { + throw new Error('Current value is not an object'); + } + for ( const [path, amount] of Object.entries(pathAndAmountMap) ) { + const pathParts = path.split('.'); + let obj = currVal; + if ( obj === null ) + { + continue; + } + for ( let i = 0; i < pathParts.length - 1; i++ ) { + const part = pathParts[i]; + if ( ! obj[part] ) { + obj[part] = {}; + } + if ( typeof obj[part] !== 'object' || Array.isArray(currVal) ) { + throw new Error(`Path ${pathParts.slice(0, i + 1).join('.')} is not an object`); + } + obj = obj[part]; + } + if ( obj === null ) + { + continue; + } + const lastPart = pathParts[pathParts.length - 1]; + if ( ! obj[lastPart] ) { + obj[lastPart] = 0; + } + if ( typeof obj[lastPart] !== 'number' ) { + throw new Error(`Value at path ${path} is not a number`); + } + obj[lastPart] += amount; + } + this.set({ key, value: currVal }); + return currVal; + } + async decr ({ key, pathAndAmountMap }) { + return this.incr({ key, pathAndAmountMap: Object.fromEntries(Object.entries(pathAndAmountMap).map(([k, v]) => [k, -v])) }); + } + async #expireat (key, timestamp) { + const actor = Context.get('actor'); + const app = actor.type?.app ?? undefined; + const user = actor.type?.user ?? undefined; + if ( ! user ) { + throw new Error('User not found'); + } + const key_hash = murmurhash.v3(key); + try { + await this.#db.write(`INSERT INTO kv (user_id, app, kkey_hash, kkey, value, expireAt) + VALUES (?, ?, ?, ?, ?, ?) ${this.#db.case({ + mysql: 'ON DUPLICATE KEY UPDATE expireAt = ?', + sqlite: 'ON CONFLICT(user_id, app, kkey_hash) DO UPDATE SET expireAt = excluded.expireAt', + })}`, [ + user.id, + app?.uid ?? GLOBAL_APP_KEY, + key_hash, + key, + undefined, + timestamp, + ...this.#db.case({ mysql: [timestamp], otherwise: [] }), + ]); + } + catch (e) { + console.error(e); + } + } +} +//# sourceMappingURL=DBKVStore.js.map \ No newline at end of file diff --git a/src/backend/src/services/repositories/DDBClient.js b/src/backend/src/services/repositories/DDBClient.js new file mode 100644 index 000000000..b4e0324b9 --- /dev/null +++ b/src/backend/src/services/repositories/DDBClient.js @@ -0,0 +1,196 @@ +import { CreateTableCommand, DynamoDBClient, UpdateTimeToLiveCommand } from '@aws-sdk/client-dynamodb'; +import { BatchGetCommand, DeleteCommand, DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { NodeHttpHandler } from '@smithy/node-http-handler'; +import dynalite from 'dynalite'; +import { once } from 'node:events'; +import { Agent as httpsAgent } from 'node:https'; +export class DDBClient { + ddbClientPromise; + #documentClient; + config; + constructor (config) { + this.config = config; + this.ddbClientPromise = this.#getClient(); + this.ddbClientPromise.then(client => { + this.#documentClient = DynamoDBDocumentClient.from(client, { + marshallOptions: { + removeUndefinedValues: true, + }, + }); + }); + } + async recreateClient () { + this.ddbClientPromise = this.#getClient(); + this.#documentClient = DynamoDBDocumentClient.from(await this.ddbClientPromise, { + marshallOptions: { + removeUndefinedValues: true, + }, + }); + } + async #getClient () { + if ( ! this.config?.aws ) { + console.warn('No config for DynamoDB, will fall back on local dynalite'); + const dynaliteInstance = dynalite({ createTableMs: 0, path: this.config?.path === ':memory:' ? undefined : this.config?.path || './puter-ddb' }); + const dynaliteServer = dynaliteInstance.listen(0, '127.0.0.1'); + await once(dynaliteServer, 'listening'); + const address = dynaliteServer.address(); + const port = (typeof address === 'object' && address ? address.port : undefined) || 4567; + const dynamoEndpoint = `http://127.0.0.1:${port}`; + return new DynamoDBClient({ + credentials: { + accessKeyId: 'fake', + secretAccessKey: 'fake', + }, + maxAttempts: 3, + requestHandler: new NodeHttpHandler({ + connectionTimeout: 5000, + requestTimeout: 5000, + httpsAgent: new httpsAgent({ keepAlive: true }), + }), + endpoint: dynamoEndpoint, + region: 'us-west-2', + }); + } + return new DynamoDBClient({ + credentials: { + accessKeyId: this.config.aws.access_key, + secretAccessKey: this.config.aws.secret_key, + }, + maxAttempts: 3, + requestHandler: new NodeHttpHandler({ + connectionTimeout: 5000, + requestTimeout: 5000, + httpsAgent: new httpsAgent({ keepAlive: true }), + }), + ...(this.config.endpoint ? { endpoint: this.config.endpoint } : {}), + region: this.config.aws.region || 'us-west-2', + }); + } + async get (table, key, consistentRead = false) { + const command = new GetCommand({ + TableName: table, + Key: key, + ConsistentRead: consistentRead, + ReturnConsumedCapacity: 'TOTAL', + }); + const response = await this.#documentClient.send(command); + return response; + } + async put (table, item) { + const command = new PutCommand({ + TableName: table, + Item: item, + ReturnConsumedCapacity: 'TOTAL', + }); + const response = await this.#documentClient.send(command); + return response; + } + async batchGet (params, consistentRead = false) { + const allRequestItemsPerTable = params.reduce((acc, curr) => { + if ( ! acc[curr.table] ) + { + acc[curr.table] = []; + } + acc[curr.table].push(curr.items); + return acc; + }, {}); + const RequestItems = Object.entries(allRequestItemsPerTable).reduce((acc, [table, keyList]) => { + const Keys = keyList; + acc[table] = { + Keys, + ConsistentRead: consistentRead, + }; + return acc; + }, {}); + const command = new BatchGetCommand({ + RequestItems, + ReturnConsumedCapacity: 'TOTAL', + }); + return this.#documentClient.send(command); + } + async del (table, key) { + const command = new DeleteCommand({ + TableName: table, + Key: key, + ReturnConsumedCapacity: 'TOTAL', + }); + return this.#documentClient.send(command); + } + async query (table, keys, limit = 0, pageKey, index = '', consistentRead = false, options) { + const keyExpressionParts = Object.keys(keys).map(key => `#${key} = :${key}`); + const expressionAttributeValues = Object.entries(keys).reduce((acc, [key, value]) => { + acc[`:${key}`] = value; + return acc; + }, {}); + const expressionAttributeNames = Object.keys(keys).reduce((acc, key) => { + acc[`#${key}`] = key; + return acc; + }, {}); + if ( options?.beginsWith?.key && typeof options.beginsWith.value === 'string' && options.beginsWith.value !== '' ) { + const beginsKey = options.beginsWith.key; + const beginsValueToken = `:${beginsKey}_begins_with`; + keyExpressionParts.push(`begins_with(#${beginsKey}, ${beginsValueToken})`); + expressionAttributeValues[beginsValueToken] = options.beginsWith.value; + expressionAttributeNames[`#${beginsKey}`] = beginsKey; + } + const keyExpression = keyExpressionParts.join(' AND '); + const command = new QueryCommand({ + TableName: table, + ...(!index ? {} : { IndexName: index }), + KeyConditionExpression: keyExpression, + ExpressionAttributeValues: expressionAttributeValues, + ExpressionAttributeNames: expressionAttributeNames, + ConsistentRead: consistentRead, + ...(!pageKey ? {} : { ExclusiveStartKey: pageKey }), + ...(!limit ? {} : { Limit: limit }), + ReturnConsumedCapacity: 'TOTAL', + }); + return await this.#documentClient.send(command); + } + async update (table, key, expression, expressionValues, expressionNames) { + const hasValues = !!expressionValues && Object.keys(expressionValues).length > 0; + const hasNames = !!expressionNames && Object.keys(expressionNames).length > 0; + const command = new UpdateCommand({ + TableName: table, + Key: key, + UpdateExpression: expression, + ...(hasValues ? { ExpressionAttributeValues: expressionValues } : {}), + ...(hasNames ? { ExpressionAttributeNames: expressionNames } : {}), + ReturnValues: 'ALL_NEW', + ReturnConsumedCapacity: 'TOTAL', + }); + try { + return await this.#documentClient.send(command); + } + catch (e) { + console.error('DDB Update Error', e); + throw e; + } + } + async createTableIfNotExists (params, ttlAttribute) { + if ( this.config?.aws ) { + console.warn('Creating DynamoDB tables in AWS is disabled by default, but if you need to enable it, modify the DDBClient class'); + return; + } + try { + await this.#documentClient.send(new CreateTableCommand(params)); + } + catch (e) { + if ( e?.name !== 'ResourceInUseException' ) { + throw e; + } + setTimeout(async () => { + if ( ttlAttribute ) { + await this.#documentClient.send(new UpdateTimeToLiveCommand({ + TableName: params.TableName, + TimeToLiveSpecification: { + AttributeName: ttlAttribute, + Enabled: true, + }, + })); + } + }, 5000); + } + } +} +//# sourceMappingURL=DDBClient.js.map \ No newline at end of file diff --git a/src/backend/src/services/repositories/DDBClientWrapper.js b/src/backend/src/services/repositories/DDBClientWrapper.js new file mode 100644 index 000000000..426dc7b9e --- /dev/null +++ b/src/backend/src/services/repositories/DDBClientWrapper.js @@ -0,0 +1,18 @@ +import { BaseService } from '@heyputer/backend/src/services/BaseService.js'; +import { DDBClient } from './DDBClient.js'; +class DDBClientServiceWrapper extends BaseService { + ddbClient; + async _construct () { + this.ddbClient = new DDBClient(this.config); + await this.ddbClient.ddbClientPromise; + Object.getOwnPropertyNames(DDBClient.prototype).forEach(fn => { + if ( fn === 'constructor' ) + { + return; + } + this[fn] = (...args) => this.ddbClient[fn](...args); + }); + } +} +export const DDBClientWrapper = DDBClientServiceWrapper; +//# sourceMappingURL=DDBClientWrapper.js.map \ No newline at end of file diff --git a/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.js b/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.js new file mode 100644 index 000000000..687e00de5 --- /dev/null +++ b/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.js @@ -0,0 +1,647 @@ +import { SystemActorType } from '@heyputer/backend/src/services/auth/Actor.js'; +import { Context } from '@heyputer/backend/src/util/context.js'; +import murmurhash from 'murmurhash'; +import { PUTER_KV_STORE_TABLE_DEFINITION } from './tableDefinition.js'; +import APIError from '../../../api/APIError.js'; +export class DynamoKVStore { + static GLOBAL_APP_KEY = 'os-global'; + static LEGACY_GLOBAL_APP_KEY = 'global'; + #ddbClient; + #sqlClient; + #meteringService; + #tableName = 'store-kv-v1'; + #pathCleanerRegex = /[:\-+/*]/g; + #enableMigrationFromSQL = false; + constructor ({ ddbClient, sqlClient, tableName, meteringService }) { + this.#ddbClient = ddbClient; + this.#sqlClient = sqlClient; + this.#tableName = tableName; + this.#meteringService = meteringService; + this.#enableMigrationFromSQL = !this.#ddbClient.config?.aws; + } + async createTableIfNotExists () { + if ( ! this.#enableMigrationFromSQL ) + { + return; + } + await this.#ddbClient.createTableIfNotExists({ ...PUTER_KV_STORE_TABLE_DEFINITION, TableName: this.#tableName }, 'ttl'); + } + #getNameSpace (actor) { + if ( actor.type instanceof SystemActorType ) { + return 'v1:system'; + } + else { + const app = actor.type?.app ?? undefined; + const user = actor.type?.user ?? undefined; + if ( ! user ) + { + throw new Error('User not found'); + } + return `v1:${app ? `${user.uuid}:${app.uid}` + : `${user.uuid}:${this.#enableMigrationFromSQL ? DynamoKVStore.LEGACY_GLOBAL_APP_KEY : DynamoKVStore.GLOBAL_APP_KEY}`}`; + } + } + async get ({ key }) { + if ( key === '' ) { + throw APIError.create('field_empty', null, { + key: 'key', + }); + } + const actor = Context.get('actor'); + const app = actor.type?.app ?? undefined; + const user = actor.type?.user ?? undefined; + const namespace = this.#getNameSpace(actor); + const multi = Array.isArray(key); + const keys = multi ? key : [key]; + const values = []; + let kvEntries; + let usage; + if ( multi ) { + const entriesAndUsage = (await this.#getBatches(namespace, keys)); + kvEntries = entriesAndUsage.kvEntries; + usage = entriesAndUsage.usage; + } + else { + const res = await this.#ddbClient.get(this.#tableName, { namespace, key }); + kvEntries = res.Item ? [res.Item] : []; + usage = res.ConsumedCapacity?.CapacityUnits ?? 0; + } + this.#meteringService.incrementUsage(actor, 'kv:read', usage || 0); + for ( const key of keys ) { + const kv_entry = kvEntries?.find(e => e.key === key); + const time = Date.now() / 1000; + if ( kv_entry?.ttl && kv_entry.ttl <= (time) ) { + values.push(null); + continue; + } + if ( kv_entry?.value ) { + values.push(kv_entry.value); + continue; + } + if ( this.#enableMigrationFromSQL ) { + const key_hash = murmurhash.v3(key); + const kv_row = await this.#sqlClient.read('SELECT * FROM kv WHERE user_id=? AND app=? AND kkey_hash=? LIMIT 1', [user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY, key_hash]); + if ( kv_row[0]?.value ) { + (async () => { + await this.set({ key: kv_row[0].key, value: kv_row[0].value }); + await this.#sqlClient.write('DELETE FROM kv WHERE user_id=? AND app=? AND kkey_hash=?', [user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY, key_hash]); + })(); + values.push(kv_row[0]?.value); + continue; + } + } + values.push(kv_entry?.value ?? null); + } + return multi ? values : values[0]; + } + async #getBatches (namespace, allKeys) { + const batches = []; + for ( let i = 0; i < allKeys.length; i += 100 ) { + batches.push(allKeys.slice(i, i + 100)); + } + const batchPromises = batches.map(async (keys) => { + const requests = [...new Set(keys)].map(k => ({ table: this.#tableName, items: { namespace, key: k } })); + const res = await this.#ddbClient.batchGet(requests); + const kvEntries = res.Responses?.[this.#tableName]; + const usage = res.ConsumedCapacity?.reduce((acc, curr) => acc + (curr.CapacityUnits ?? 0), 0); + return { kvEntries, usage }; + }); + const batchGets = await Promise.all(batchPromises); + return batchGets.reduce((acc, curr) => { + acc.kvEntries.push(...curr?.kvEntries ?? []); + acc.usage += curr.usage || 0; + return acc; + }, { kvEntries: [], usage: 0 }); + } + async set ({ key, value, expireAt }) { + const context = Context.get(); + const actor = context.get('actor'); + if ( key === '' ) { + throw APIError.create('field_empty', undefined, { + key: 'key', + }); + } + key = String(key); + if ( Buffer.byteLength(key, 'utf8') > 1024 ) { + throw new Error(`key is too large. Max size is ${1024}.`); + } + if ( this.#enableMigrationFromSQL ) { + this.get({ key }); + } + const namespace = this.#getNameSpace(actor); + const res = await this.#ddbClient.put(this.#tableName, { + namespace, + key, + value, + ttl: expireAt, + }); + this.#meteringService.incrementUsage(actor, 'kv:write', res?.ConsumedCapacity?.CapacityUnits ?? 1); + return true; + } + async del ({ key }) { + const actor = Context.get('actor'); + const app = actor.type?.app ?? undefined; + const user = actor.type?.user ?? undefined; + if ( ! user ) + { + throw new Error('User not found'); + } + const namespace = this.#getNameSpace(actor); + const res = await this.#ddbClient.del(this.#tableName, { + namespace, + key, + }); + this.#meteringService.incrementUsage(actor, 'kv:write', res?.ConsumedCapacity?.CapacityUnits ?? 1); + if ( this.#enableMigrationFromSQL ) { + const key_hash = murmurhash.v3(key); + await this.#sqlClient.write('DELETE FROM kv WHERE user_id=? AND app=? AND kkey_hash=?', [user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY, key_hash]); + } + return true; + } + #encodeCursor (pageKey) { + if ( !pageKey || Object.keys(pageKey).length === 0 ) { + return undefined; + } + return Buffer.from(JSON.stringify(pageKey)).toString('base64'); + } + #decodeCursor (cursor) { + if ( ! cursor ) { + return undefined; + } + if ( typeof cursor === 'object' ) { + return cursor; + } + if ( typeof cursor !== 'string' ) { + throw APIError.create('field_invalid', undefined, { + key: 'cursor', + }); + } + const trimmed = cursor.trim(); + if ( trimmed === '' ) { + return undefined; + } + try { + const decoded = Buffer.from(trimmed, 'base64').toString('utf8'); + return JSON.parse(decoded); + } + catch (e) { + try { + return JSON.parse(trimmed); + } + catch ( err ) { + throw APIError.create('field_invalid', undefined, { + key: 'cursor', + }); + } + } + } + #normalizeLimit (limit) { + if ( limit === undefined || limit === null ) { + return undefined; + } + const parsed = Number(limit); + if ( !Number.isFinite(parsed) || parsed <= 0 ) { + throw APIError.create('field_invalid', undefined, { + key: 'limit', + expected: 'positive number', + }); + } + return Math.floor(parsed); + } + #normalizePattern (pattern) { + if ( pattern === undefined || pattern === null ) { + return undefined; + } + if ( typeof pattern !== 'string' ) { + throw APIError.create('field_invalid', undefined, { + key: 'pattern', + }); + } + const trimmed = pattern.trim(); + if ( trimmed === '' ) { + return undefined; + } + if ( trimmed.endsWith('*') ) { + const prefix = trimmed.slice(0, -1); + return prefix === '' ? undefined : prefix; + } + return trimmed; + } + async list ({ as, limit, cursor, pattern }) { + const actor = Context.get('actor'); + const app = actor.type?.app ?? undefined; + const user = actor.type?.user ?? undefined; + if ( ! user ) + { + throw new Error('User not found'); + } + const namespace = this.#getNameSpace(actor); + const normalizedLimit = this.#normalizeLimit(limit); + const pageKey = this.#decodeCursor(cursor); + const normalizedPattern = this.#normalizePattern(pattern); + const paginated = normalizedLimit !== undefined || pageKey !== undefined; + const entriesRes = await this.#ddbClient.query(this.#tableName, { namespace }, normalizedLimit ?? 0, pageKey, '', false, normalizedPattern ? { beginsWith: { key: 'key', value: normalizedPattern } } : undefined); + this.#meteringService.incrementUsage(actor, 'kv:read', entriesRes.ConsumedCapacity?.CapacityUnits ?? 1); + let entries = entriesRes.Items ?? []; + entries = entries?.filter(entry => { + if ( ! entry ) { + return false; + } + if ( entry.ttl && entry.ttl <= (Date.now() / 1000) ) { + return false; + } + return true; + }); + if ( this.#enableMigrationFromSQL && !paginated ) { + const oldEntries = await this.#sqlClient.read('SELECT * FROM kv WHERE user_id=? AND app=?', [user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY]); + oldEntries.forEach(oldEntry => { + if ( normalizedPattern && !oldEntry.kkey?.startsWith(normalizedPattern) ) { + return; + } + if ( ! entries.find(e => e.key === oldEntry.kkey) ) { + if ( oldEntry.ttl && oldEntry.ttl <= (Date.now() / 1000) ) { + entries.push({ key: oldEntry.kkey, value: oldEntry.value }); + } + } + }); + } + entries = entries?.map(entry => ({ + key: entry.key, + value: entry.value, + })); + as = as || 'entries'; + if ( ! ['keys', 'values', 'entries'].includes(as) ) { + throw APIError.create('field_invalid', undefined, { + key: 'as', + expected: '"keys", "values", or "entries"', + }); + } + let items = entries; + if ( as === 'keys' ) + { + items = entries.map(entry => entry.key); + } + else if ( as === 'values' ) + { + items = entries.map(entry => entry.value); + } + if ( paginated ) { + const nextCursor = this.#encodeCursor(entriesRes.LastEvaluatedKey); + if ( nextCursor ) { + return { items, cursor: nextCursor }; + } + return { items }; + } + return items; + } + async flush () { + const actor = Context.get('actor'); + const app = actor.type.app ?? undefined; + const user = actor.type?.user ?? undefined; + if ( ! user ) + { + throw new Error('User not found'); + } + const namespace = this.#getNameSpace(actor); + const entriesRes = await this.#ddbClient.query(this.#tableName, { namespace }); + const entries = entriesRes.Items ?? []; + const readUsage = entriesRes?.ConsumedCapacity?.CapacityUnits ?? 0; + this.#meteringService.incrementUsage(actor, 'kv:read', readUsage); + const allRes = (await Promise.all(entries.map(entry => { + try { + return this.#ddbClient.del(this.#tableName, { + namespace, + key: entry.key, + }); + } + catch (e) { + console.error('Error deleting key', entry.key, e); + } + }))).filter(Boolean); + const writeUsage = allRes.reduce((acc, curr) => acc + (curr?.ConsumedCapacity?.CapacityUnits ?? 0), 0); + this.#meteringService.incrementUsage(actor, 'kv:write', writeUsage); + if ( this.#enableMigrationFromSQL ) { + await this.#sqlClient.write('DELETE FROM kv WHERE user_id=? AND app=?', [user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY]); + } + return !!allRes; + } + async expireAt ({ key, timestamp }) { + if ( key === '' ) { + throw APIError.create('field_empty', null, { + key: 'key', + }); + } + timestamp = Number(timestamp); + return await this.#expireAt(key, timestamp); + } + async expire ({ key, ttl }) { + if ( key === '' ) { + throw APIError.create('field_empty', null, { + key: 'key', + }); + } + ttl = Number(ttl); + let timestamp = Math.floor(Date.now() / 1000) + ttl; + return await this.#expireAt(key, timestamp); + } + async #createPaths (namespace, key, pathList) { + const nestedMapValue = (() => { + const valueRoot = {}; + let hasPaths = false; + pathList.forEach((valPath) => { + if ( ! valPath ) + { + return; + } + hasPaths = true; + const chunks = valPath.split('.').filter(Boolean); + let cursor = valueRoot; + for ( let i = 0; i < chunks.length - 1; i++ ) { + const chunk = chunks[i]; + const existing = cursor[chunk]; + if ( !existing || typeof existing !== 'object' || Array.isArray(existing) ) { + cursor[chunk] = {}; + } + cursor = cursor[chunk]; + } + }); + return hasPaths ? valueRoot : null; + })(); + if ( ! nestedMapValue ) { + return 0; + } + const isPlainObject = (value) => { + return !!value && typeof value === 'object' && !Array.isArray(value); + }; + const objectsEqual = (left, right) => { + if ( left === right ) + { + return true; + } + if ( !isPlainObject(left) || !isPlainObject(right) ) + { + return false; + } + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if ( leftKeys.length !== rightKeys.length ) + { + return false; + } + for ( const key of leftKeys ) { + if ( ! rightKeys.includes(key) ) + { + return false; + } + if ( ! objectsEqual(left[key], right[key]) ) + { + return false; + } + } + return true; + }; + const allIntermediatePaths = new Set(); + pathList.forEach((valPath) => { + const chunks = ['value', ...valPath.split('.')].filter(Boolean); + for ( let i = 1; i < chunks.length; i++ ) { + const subPath = chunks.slice(0, i).join('.'); + allIntermediatePaths.add(subPath); + } + }); + let writeUnits = 0; + const orderedPaths = [...allIntermediatePaths] + .sort((left, right) => left.split('.').length - right.split('.').length); + for ( const layerPath of orderedPaths ) { + const chunks = layerPath.split('.'); + const attrName = chunks.map((chunk) => `#${chunk}`.replaceAll(this.#pathCleanerRegex, '')).join('.'); + const expressionNames = {}; + chunks.forEach((chunk) => { + const cleanedChunk = chunk.split(/\[\d*\]/g)[0]; + expressionNames[`#${cleanedChunk}`.replaceAll(this.#pathCleanerRegex, '')] = cleanedChunk; + }); + const isRootLayer = layerPath === 'value'; + const expressionValues = isRootLayer + ? { ':nestedMap': nestedMapValue } + : { ':emptyMap': {} }; + const valueToken = isRootLayer ? ':nestedMap' : ':emptyMap'; + const layerUpsertRes = await this.#ddbClient.update(this.#tableName, { key, namespace }, `SET ${attrName} = if_not_exists(${attrName}, ${valueToken})`, expressionValues, expressionNames); + writeUnits += layerUpsertRes.ConsumedCapacity?.CapacityUnits ?? 0; + if ( isRootLayer && objectsEqual(layerUpsertRes.Attributes?.value, nestedMapValue) ) { + return writeUnits; + } + } + return writeUnits; + } + async incr ({ key, pathAndAmountMap }) { + if ( Object.values(pathAndAmountMap).find((v) => typeof v !== 'number') ) { + throw new Error('All values in pathAndAmountMap must be numbers'); + } + if ( key === '' ) { + throw APIError.create('field_empty', null, { + key: 'key', + }); + } + if ( ! pathAndAmountMap ) { + throw new Error('invalid use of #incr: no pathAndAmountMap'); + } + const actor = Context.get('actor'); + const user = actor.type?.user ?? undefined; + if ( ! user ) + { + throw new Error('User not found'); + } + const namespace = this.#getNameSpace(actor); + if ( this.#enableMigrationFromSQL ) { + await this.get({ key }); + } + const cleanerRegex = /[:\-+/*]/g; + let writeUnits = await this.#createPaths(namespace, key, Object.keys(pathAndAmountMap)); + const setStatements = Object.entries(pathAndAmountMap).map(([valPath, _amt], idx) => { + const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); + const attrName = path.split('.').map((chunk) => `#${chunk}`.replaceAll(cleanerRegex, '')).join('.'); + return `${attrName} = if_not_exists(${attrName}, :start${idx}) + :incr${idx}`; + }); + const valueAttributeValues = Object.entries(pathAndAmountMap).reduce((acc, [_path, amt], idx) => { + acc[`:incr${idx}`] = amt; + acc[`:start${idx}`] = 0; + return acc; + }, {}); + const valueAttributeNames = Object.entries(pathAndAmountMap).reduce((acc, [valPath, _amt]) => { + const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); + path.split('.').forEach((chunk) => { + const cleanedChunk = chunk.split(/\[\d*\]/g)[0]; + acc[`#${cleanedChunk}`.replaceAll(cleanerRegex, '')] = cleanedChunk; + }); + return acc; + }, {}); + const res = await this.#ddbClient.update(this.#tableName, { key, namespace }, `SET ${[...setStatements].join(', ')}`, valueAttributeValues, { ...valueAttributeNames, '#value': 'value' }); + writeUnits += res.ConsumedCapacity?.CapacityUnits ?? 0; + this.#meteringService.incrementUsage(actor, 'kv:write', writeUnits); + return res.Attributes?.value; + } + async add ({ key, pathAndValueMap }) { + if ( !pathAndValueMap || Object.keys(pathAndValueMap).length === 0 ) { + throw new Error('invalid use of #add: no pathAndValueMap'); + } + if ( key === '' ) { + throw APIError.create('field_empty', null, { + key: 'key', + }); + } + const actor = Context.get('actor'); + const user = actor.type?.user ?? undefined; + if ( ! user ) + { + throw new Error('User not found'); + } + const namespace = this.#getNameSpace(actor); + if ( this.#enableMigrationFromSQL ) { + await this.get({ key }); + } + const cleanerRegex = /[:\-+/*]/g; + let writeUnits = await this.#createPaths(namespace, key, Object.keys(pathAndValueMap)); + const setStatements = Object.entries(pathAndValueMap).map(([valPath, _val], idx) => { + const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); + const attrName = path.split('.').map((chunk) => `#${chunk}`.replaceAll(cleanerRegex, '')).join('.'); + return `${attrName} = list_append(if_not_exists(${attrName}, :emptyList${idx}), :append${idx})`; + }); + const valueAttributeValues = Object.entries(pathAndValueMap).reduce((acc, [_path, val], idx) => { + acc[`:append${idx}`] = Array.isArray(val) ? val : [val]; + acc[`:emptyList${idx}`] = []; + return acc; + }, {}); + const valueAttributeNames = Object.entries(pathAndValueMap).reduce((acc, [valPath, _val]) => { + const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); + path.split('.').forEach((chunk) => { + const cleanedChunk = chunk.split(/\[\d*\]/g)[0]; + acc[`#${cleanedChunk}`.replaceAll(cleanerRegex, '')] = cleanedChunk; + }); + return acc; + }, {}); + const res = await this.#ddbClient.update(this.#tableName, { key, namespace }, `SET ${[...setStatements].join(', ')}`, valueAttributeValues, { ...valueAttributeNames, '#value': 'value' }); + writeUnits += res.ConsumedCapacity?.CapacityUnits ?? 0; + this.#meteringService.incrementUsage(actor, 'kv:write', writeUnits); + return res.Attributes?.value; + } + async remove ({ key, paths }) { + if ( !paths || paths.length === 0 ) { + throw new Error('invalid use of #remove: no paths'); + } + if ( key === '' ) { + throw APIError.create('field_empty', null, { + key: 'key', + }); + } + const actor = Context.get('actor'); + const user = actor.type?.user ?? undefined; + if ( ! user ) + { + throw new Error('User not found'); + } + const namespace = this.#getNameSpace(actor); + if ( this.#enableMigrationFromSQL ) { + await this.get({ key }); + } + const cleanerRegex = /[:\-+/*]/g; + const removeStatements = paths.map((valPath) => { + const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); + return path.split('.').map((chunk) => { + const cleanedChunk = chunk.split(/\[\d*\]/g)[0]; + const indexSuffix = chunk.slice(cleanedChunk.length); + return `${`#${cleanedChunk}`.replaceAll(cleanerRegex, '')}${indexSuffix}`; + }).join('.'); + }); + const valueAttributeNames = paths.reduce((acc, valPath) => { + const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); + path.split('.').forEach((chunk) => { + const cleanedChunk = chunk.split(/\[\d*\]/g)[0]; + acc[`#${cleanedChunk}`.replaceAll(cleanerRegex, '')] = cleanedChunk; + }); + return acc; + }, {}); + try { + const res = await this.#ddbClient.update(this.#tableName, { key, namespace }, `REMOVE ${removeStatements.join(', ')}`, undefined, { ...valueAttributeNames, '#value': 'value' }); + this.#meteringService.incrementUsage(actor, 'kv:write', res?.ConsumedCapacity?.CapacityUnits ?? 1); + return res.Attributes?.value; + } + catch (e) { + const message = e?.message ?? ''; + if ( e?.name === 'ValidationException' && /document path|invalid updateexpression/i.test(message) ) { + this.#meteringService.incrementUsage(actor, 'kv:write', 1); + return await this.get({ key }); + } + throw e; + } + } + async update ({ key, pathAndValueMap, ttl }) { + if ( !pathAndValueMap || Object.keys(pathAndValueMap).length === 0 ) { + throw new Error('invalid use of #update: no pathAndValueMap'); + } + if ( key === '' ) { + throw APIError.create('field_empty', null, { + key: 'key', + }); + } + const actor = Context.get('actor'); + const user = actor.type?.user ?? undefined; + if ( ! user ) + { + throw new Error('User not found'); + } + const namespace = this.#getNameSpace(actor); + if ( this.#enableMigrationFromSQL ) { + await this.get({ key }); + } + const cleanerRegex = /[:\-+/*]/g; + let writeUnits = await this.#createPaths(namespace, key, Object.keys(pathAndValueMap)); + const setStatements = Object.entries(pathAndValueMap).map(([valPath, _val], idx) => { + const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); + const attrName = path.split('.').map((chunk) => `#${chunk}`.replaceAll(cleanerRegex, '')).join('.'); + return `${attrName} = :value${idx}`; + }); + const valueAttributeValues = Object.entries(pathAndValueMap).reduce((acc, [_path, val], idx) => { + acc[`:value${idx}`] = val; + return acc; + }, {}); + const valueAttributeNames = Object.entries(pathAndValueMap).reduce((acc, [valPath, _val]) => { + const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); + path.split('.').forEach((chunk) => { + const cleanedChunk = chunk.split(/\[\d*\]/g)[0]; + acc[`#${cleanedChunk}`.replaceAll(cleanerRegex, '')] = cleanedChunk; + }); + return acc; + }, {}); + if ( ttl !== undefined ) { + const ttlSeconds = Number(ttl); + if ( Number.isNaN(ttlSeconds) ) { + throw new Error('ttl must be a number'); + } + const timestamp = Math.floor(Date.now() / 1000) + ttlSeconds; + setStatements.push('#ttl = :ttl'); + valueAttributeValues[':ttl'] = timestamp; + valueAttributeNames['#ttl'] = 'ttl'; + } + const res = await this.#ddbClient.update(this.#tableName, { key, namespace }, `SET ${[...setStatements].join(', ')}`, valueAttributeValues, { ...valueAttributeNames, '#value': 'value' }); + writeUnits += res.ConsumedCapacity?.CapacityUnits ?? 0; + this.#meteringService.incrementUsage(actor, 'kv:write', writeUnits); + return res.Attributes?.value; + } + async decr ({ key, pathAndAmountMap }) { + return await this.incr({ key, pathAndAmountMap: Object.fromEntries(Object.entries(pathAndAmountMap).map(([k, v]) => [k, -v])) }); + } + async #expireAt (key, timestamp) { + const actor = Context.get('actor'); + const user = actor.type?.user ?? undefined; + if ( ! user ) + { + throw new Error('User not found'); + } + const namespace = this.#getNameSpace(actor); + if ( this.#enableMigrationFromSQL ) { + await this.get({ key }); + } + const res = await this.#ddbClient.update(this.#tableName, { key, namespace }, 'SET #ttl = :ttl, #value = if_not_exists(#value, :defaultValue)', { ':ttl': timestamp, ':defaultValue': null }, { '#ttl': 'ttl', '#value': 'value' }); + this.#meteringService.incrementUsage(actor, 'kv:write', res?.ConsumedCapacity?.CapacityUnits ?? 1); + } +} +//# sourceMappingURL=DynamoKVStore.js.map \ No newline at end of file diff --git a/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStoreWrapper.js b/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStoreWrapper.js new file mode 100644 index 000000000..6af6e92ee --- /dev/null +++ b/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStoreWrapper.js @@ -0,0 +1,55 @@ +import { BaseService } from '@heyputer/backend/src/services/BaseService.js'; +import { DynamoKVStore } from './DynamoKVStore.js'; +class DynamoKVStoreServiceWrapper extends BaseService { + kvStore; + async _init () { + this.kvStore = new DynamoKVStore({ + ddbClient: this.services.get('dynamo'), + sqlClient: this.services.get('database').get(), + meteringService: this.services.get('meteringService').meteringService, + tableName: this.config.tableName || 'store-kv-v1', + }); + await this.kvStore.createTableIfNotExists(); + Object.getOwnPropertyNames(DynamoKVStore.prototype).forEach(fn => { + if ( fn === 'constructor' ) + { + return; + } + this[fn] = (...args) => this.kvStore[fn](...args); + }); + } + async registerHealthcheck () { + const healthcheckService = this.services.get('server-health'); + healthcheckService.add_check('kv-store', async () => { + try { + const passed = await this.services.get('su').sudo(async () => { + const rand = Math.floor(Math.random() * 1000000); + await this.kvStore.set({ key: 'healthTestKey', value: rand }); + const setRight = await this.kvStore.get({ key: 'healthTestKey' }) === rand; + await this.kvStore.del({ key: 'healthTestKey' }); + return setRight; + }); + if ( ! passed ) { + throw new Error('KV Store healthcheck failed: set/get mismatch'); + } + } + catch (e) { + throw new Error(`KV Store healthcheck failed: ${e.message}`); + } + }).on_fail(async () => { + await this.services.get('dynamo').recreateClient(); + }); + } + static IMPLEMENTS = { + ['puter-kvstore']: Object.getOwnPropertyNames(DynamoKVStore.prototype) + .filter(n => n !== 'constructor') + .reduce((acc, fn) => ({ + ...acc, + [fn]: async function (...a) { + return await this.kvStore[fn](...a); + }, + }), {}), + }; +} +export const DynamoKVStoreWrapper = DynamoKVStoreServiceWrapper; +//# sourceMappingURL=DynamoKVStoreWrapper.js.map \ No newline at end of file diff --git a/src/backend/src/services/repositories/DynamoKVStore/tableDefinition.js b/src/backend/src/services/repositories/DynamoKVStore/tableDefinition.js new file mode 100644 index 000000000..f6fcbdb11 --- /dev/null +++ b/src/backend/src/services/repositories/DynamoKVStore/tableDefinition.js @@ -0,0 +1,24 @@ +export const PUTER_KV_STORE_TABLE_DEFINITION = { + TableName: 'store-kv-v1', + BillingMode: 'PAY_PER_REQUEST', + AttributeDefinitions: [ + { AttributeName: 'namespace', AttributeType: 'S' }, + { AttributeName: 'key', AttributeType: 'S' }, + { AttributeName: 'lsi1', AttributeType: 'S' }, + ], + KeySchema: [ + { AttributeName: 'namespace', KeyType: 'HASH' }, + { AttributeName: 'key', KeyType: 'RANGE' }, + ], + LocalSecondaryIndexes: [ + { + IndexName: 'lsi1-index', + KeySchema: [ + { AttributeName: 'namespace', KeyType: 'HASH' }, + { AttributeName: 'lsi1', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'ALL' }, + }, + ], +}; +//# sourceMappingURL=tableDefinition.js.map \ No newline at end of file diff --git a/src/gui/src/UI/UIWindowLogin.js b/src/gui/src/UI/UIWindowLogin.js index ff62d7d87..8f660f9ec 100644 --- a/src/gui/src/UI/UIWindowLogin.js +++ b/src/gui/src/UI/UIWindowLogin.js @@ -17,17 +17,17 @@ * along with this program. If not, see . */ -import UIWindow from './UIWindow.js'; -import UIWindowSignup from './UIWindowSignup.js'; -import UIWindowRecoverPassword from './UIWindowRecoverPassword.js'; import TeePromise from '../util/TeePromise.js'; -import UIComponentWindow from './UIComponentWindow.js'; -import Flexer from './Components/Flexer.js'; -import CodeEntryView from './Components/CodeEntryView.js'; -import JustHTML from './Components/JustHTML.js'; -import StepView from './Components/StepView.js'; import Button from './Components/Button.js'; +import CodeEntryView from './Components/CodeEntryView.js'; +import Flexer from './Components/Flexer.js'; +import JustHTML from './Components/JustHTML.js'; import RecoveryCodeEntryView from './Components/RecoveryCodeEntryView.js'; +import StepView from './Components/StepView.js'; +import UIComponentWindow from './UIComponentWindow.js'; +import UIWindow from './UIWindow.js'; +import UIWindowRecoverPassword from './UIWindowRecoverPassword.js'; +import UIWindowSignup from './UIWindowSignup.js'; async function UIWindowLogin (options) { options = options ?? {}; @@ -83,6 +83,10 @@ async function UIWindowLogin (options) { // password recovery h += `

${i18n('forgot_pass_c2a')}

`; h += ''; + h += ''; h += ''; // create account link @@ -148,6 +152,23 @@ async function UIWindowLogin (options) { }); }); + (async () => { + try { + const origin = window.gui_origin || window.location.origin; + const res = await fetch(`${origin}/auth/oidc/providers`); + if ( ! res.ok ) return; + const data = await res.json(); + if ( data.providers && data.providers.includes('google') ) { + $(el_window).find('.oidc-providers-wrapper').show(); + $(el_window).find('.oidc-google-btn').on('click', function () { + const redirectUri = encodeURIComponent(window.location.origin + (window.location.pathname || '/')); + window.location.href = `${origin}/auth/oidc/google/start?redirect_uri=${redirectUri}`; + }); + } + } catch (_) { + } + })(); + $(el_window).find('.login-btn').on('click', function (e) { // Prevent default button behavior (important for async requests) e.preventDefault(); diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 8b8d6beb3..905fd5a31 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -17,10 +17,10 @@ * along with this program. If not, see . */ -import UIWindow from './UIWindow.js'; -import UIWindowLogin from './UIWindowLogin.js'; -import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequired.js'; import check_password_strength from '../helpers/check_password_strength.js'; +import UIWindow from './UIWindow.js'; +import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequired.js'; +import UIWindowLogin from './UIWindowLogin.js'; function UIWindowSignup (options) { options = options ?? {}; @@ -96,6 +96,10 @@ function UIWindowSignup (options) { // Create Account h += ``; h += ''; + h += ''; h += ''; // login link // create account link @@ -155,6 +159,23 @@ function UIWindowSignup (options) { }; initTurnstile(); + + (async () => { + try { + const origin = window.gui_origin || window.location.origin; + const res = await fetch(`${origin}/auth/oidc/providers`); + if ( ! res.ok ) return; + const data = await res.json(); + if ( data.providers && data.providers.includes('google') ) { + $(el_window).find('.oidc-providers-wrapper').show(); + $(el_window).find('.oidc-google-btn').on('click', function () { + const redirectUri = encodeURIComponent(window.location.origin + (window.location.pathname || '/')); + window.location.href = `${origin}/auth/oidc/google/start?redirect_uri=${redirectUri}`; + }); + } + } catch (_) { + } + })(); }, window_class: 'window-signup', window_css: { From 7c8f0d5572eb02035cd0e18dd16bd4f8f2e75396 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:12:01 -0500 Subject: [PATCH 02/39] dev(backend): OIDC continued [1] This commit is rather monolithic. An attempt to split it up into smaller changes proved too difficult (as well as frustrating) and I realized it would absolutely increase the chance of having a broken commit (making bisects more difficult) unless a lot of testing effort between commits was performed, which would have very little benefit. The changes in this commit include: - Outcome utility used by SignupService for error handling - SignupService, whichs implements re-usable create_user function - Signup method in OIDCService - flow-specific callbacks in OIDC (separates login from signup) - **SEPARATE SESSION COOKIE AND GUI COOKIE** - this change "rocks the boat" the most and has the highest likelihood of causing problems --- src/backend/src/CoreModule.js | 3 + src/backend/src/api/APIError.js | 4 + src/backend/src/helpers.js | 10 + .../src/middleware/configurable_auth.js | 3 +- src/backend/src/routers/auth/oidc.js | 160 ++++++----- src/backend/src/routers/login.js | 12 +- src/backend/src/routers/logout.js | 2 + src/backend/src/routers/save_account.js | 11 +- src/backend/src/routers/signup.js | 13 +- .../src/routers/signup_create_new_user.js | 127 ++++++++ src/backend/src/services/BaseService.d.ts | 20 +- .../src/services/FeatureFlagService.js | 8 +- src/backend/src/services/UserService.js | 7 +- src/backend/src/services/auth/Actor.d.ts | 4 +- src/backend/src/services/auth/Actor.js | 17 +- src/backend/src/services/auth/AuthService.js | 102 ++++++- src/backend/src/services/auth/OIDCService.js | 139 +++------ .../src/services/auth/SignupService.js | 270 ++++++++++++++++++ .../database/BaseDatabaseAccessService.js | 2 +- .../web/UserProtectedEndpointsService.js | 7 +- src/backend/src/util/outcomeutil.js | 32 +++ src/backend/src/util/outcomeutil.ts | 77 +++++ src/backend/src/util/validutil.js | 17 +- .../src/UI/Settings/UIWindowChangeEmail.js | 8 +- src/gui/src/UI/UIWindowLogin.js | 6 +- src/gui/src/UI/UIWindowSignup.js | 6 +- src/gui/src/initgui.js | 21 +- 27 files changed, 868 insertions(+), 220 deletions(-) create mode 100644 src/backend/src/routers/signup_create_new_user.js create mode 100644 src/backend/src/services/auth/SignupService.js create mode 100644 src/backend/src/util/outcomeutil.js create mode 100644 src/backend/src/util/outcomeutil.ts diff --git a/src/backend/src/CoreModule.js b/src/backend/src/CoreModule.js index 36ebe6434..cea0be5df 100644 --- a/src/backend/src/CoreModule.js +++ b/src/backend/src/CoreModule.js @@ -272,6 +272,9 @@ const install = async ({ context, services, app, useapi, modapi }) => { const { OIDCService } = require('./services/auth/OIDCService'); services.registerService('oidc', OIDCService); + const { SignupService } = require('./services/auth/SignupService'); + services.registerService('signup', SignupService); + const { UserProtectedEndpointsService } = require('./services/web/UserProtectedEndpointsService'); services.registerService('__user-protected-endpoints', UserProtectedEndpointsService); diff --git a/src/backend/src/api/APIError.js b/src/backend/src/api/APIError.js index 9b0879bdb..496348f64 100644 --- a/src/backend/src/api/APIError.js +++ b/src/backend/src/api/APIError.js @@ -453,6 +453,10 @@ class APIError { status: 403, message: 'This endpoint must be requested with a user session', }, + 'session_required': { + status: 403, + message: 'This endpoint requires a full session (e.g. change password cannot be done with a GUI token).', + }, 'temporary_accounts_not_allowed': { status: 403, message: 'Temporary accounts cannot perform this action', diff --git a/src/backend/src/helpers.js b/src/backend/src/helpers.js index da2862220..b14f4ad55 100644 --- a/src/backend/src/helpers.js +++ b/src/backend/src/helpers.js @@ -30,6 +30,7 @@ const { NodeUIDSelector } = require('./filesystem/node/selectors'); const { redisClient } = require('./clients/redis/redisSingleton'); const { kv } = require('./util/kvSingleton'); const { APP_ICONS_SUBDOMAIN } = require('./consts/app-icons.js'); +const { generate_identifier } = require('./util/identifier'); const identifying_uuid = require('uuid').v4(); @@ -1479,6 +1480,14 @@ async function username_exists (username) { } } +async function generate_random_username () { + let username; + do { + username = generate_identifier(); + } while ( await username_exists(username) ); + return username; +} + async function app_name_exists (name) { /** @type BaseDatabaseAccessService */ const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem'); @@ -2070,6 +2079,7 @@ module.exports = { suggestedAppForFsEntry, df, username_exists, + generate_random_username, uuid2fsentry, validate_fsentry_name, validate_signature_auth, diff --git a/src/backend/src/middleware/configurable_auth.js b/src/backend/src/middleware/configurable_auth.js index e65fdb279..364acaf41 100644 --- a/src/backend/src/middleware/configurable_auth.js +++ b/src/backend/src/middleware/configurable_auth.js @@ -134,7 +134,8 @@ const configurable_auth = options => async (req, res, next) => { throw APIError.create('forbidden'); } - res.cookie(config.cookie_name, new_info.token, { + // Use session token in cookie so cookie-based requests have hasHttpPowers; client gets GUI token in response + res.cookie(config.cookie_name, new_info.session_token ?? new_info.token, { sameSite: 'none', secure: true, httpOnly: true, diff --git a/src/backend/src/routers/auth/oidc.js b/src/backend/src/routers/auth/oidc.js index ac54c2e90..52e5bee30 100644 --- a/src/backend/src/routers/auth/oidc.js +++ b/src/backend/src/routers/auth/oidc.js @@ -20,32 +20,61 @@ const express = require('express'); const router = new express.Router(); const config = require('../../config'); +const { get_user } = require('../../helpers'); -const complete_ = async ({ req, res, user }) => { +/** If Accept includes text/html, set session cookie and redirect to app; otherwise send JSON. */ +const finishOidcSuccess_ = async (req, res, user, stateDecoded) => { + console.log('okay finishOidSuccess_ is happening'); const svc_auth = req.services.get('auth'); - const { token } = await svc_auth.create_session_token(user, { req }); - res.cookie(config.cookie_name, token, { + const { session, token: session_token } = await svc_auth.create_session_token(user, { req }); + res.cookie(config.cookie_name, session_token, { sameSite: 'none', secure: true, httpOnly: true, }); - return res.send({ - proceed: true, - next_step: 'complete', - token, - user: { - username: user.username, - uuid: user.uuid, - email: user.email, - email_confirmed: user.email_confirmed, - is_temp: (user.password === null && user.email === null), - }, + console.log('what are these values?', { + stateDecoded, }); + let target = stateDecoded.redirect_uri || config.origin || '/'; + const origin = config.origin || ''; + console.log('okay what\'s target though?', { target, origin }); + if ( target && origin && !target.startsWith(origin) ) { + target = origin; + } + return res.redirect(302, target); +}; + +/** Exchange code for tokens, get userinfo; returns { provider, userinfo, stateDecoded } or sends error and returns null. */ +const oidcCallbackPreamble_ = async (req, res, callbackRedirectUri) => { + const svc_oidc = req.services.get('oidc'); + const code = req.query.code; + const state = req.query.state; + if ( !code || !state ) { + res.status(400).send('Missing code or state.'); + return null; + } + const stateDecoded = svc_oidc.verifyState(state); + if ( !stateDecoded || !stateDecoded.provider ) { + res.status(400).send('Invalid or expired state.'); + return null; + } + const provider = stateDecoded.provider; + const tokens = await svc_oidc.exchangeCodeForTokens(provider, code, callbackRedirectUri); + if ( !tokens || !tokens.access_token ) { + res.status(401).send('Token exchange failed.'); + return null; + } + const userinfo = await svc_oidc.getUserInfo(provider, tokens.access_token); + if ( !userinfo || !userinfo.sub ) { + res.status(401).send('Could not get user info.'); + return null; + } + return { provider, userinfo, stateDecoded }; }; // GET /auth/oidc/providers - list enabled provider ids for frontend router.get('/auth/oidc/providers', async (req, res) => { - if ( require('../../helpers').subdomain(req) !== 'api' && require('../../helpers').subdomain(req) !== '' ) { + if ( require('../../helpers').subdomain(req) !== 'api' ) { return res.status(404).end(); } const svc_oidc = req.services.get('oidc'); @@ -55,7 +84,7 @@ router.get('/auth/oidc/providers', async (req, res) => { // GET /auth/oidc/:provider/start - redirect to IdP authorization router.get('/auth/oidc/:provider/start', async (req, res) => { - if ( require('../../helpers').subdomain(req) !== 'api' && require('../../helpers').subdomain(req) !== '' ) { + if ( require('../../helpers').subdomain(req) !== '' ) { return res.status(404).end(); } const svc_edgeRateLimit = req.services.get('edge-rate-limit'); @@ -68,74 +97,71 @@ router.get('/auth/oidc/:provider/start', async (req, res) => { if ( ! cfg ) { return res.status(404).send('Provider not configured.'); } - const redirectUri = req.query.redirect_uri ? String(req.query.redirect_uri) : undefined; - const statePayload = { provider, redirect_uri: redirectUri }; + const flow = req.query.flow ? String(req.query.flow) : undefined; + const flowRedirects = { + login: config.origin || '/', + signup: config.origin || '/', + }; + const appRedirectUri = (flow && flowRedirects[flow]) ? flowRedirects[flow] : (config.origin || '/'); + const statePayload = { provider, redirect_uri: appRedirectUri }; const state = svc_oidc.signState(statePayload); - const url = await svc_oidc.getAuthorizationUrl(provider, state, redirectUri ? undefined : undefined); + const url = await svc_oidc.getAuthorizationUrl(provider, state, flow); if ( ! url ) { return res.status(502).send('Could not build authorization URL.'); } return res.redirect(302, url); }); -// GET /auth/oidc/callback - handle IdP redirect (code + state) -router.get('/auth/oidc/callback', async (req, res) => { - if ( require('../../helpers').subdomain(req) !== 'api' && require('../../helpers').subdomain(req) !== '' ) { +// GET /auth/oidc/callback/login - login only: existing account or abort. Never creates a user. +router.get('/auth/oidc/callback/login', async (req, res) => { + if ( require('../../helpers').subdomain(req) !== '' ) { return res.status(404).end(); } const svc_edgeRateLimit = req.services.get('edge-rate-limit'); if ( ! svc_edgeRateLimit.check('login') ) { return res.status(429).send('Too many requests.'); } - const code = req.query.code; - const state = req.query.state; - if ( !code || !state ) { - return res.status(400).send('Missing code or state.'); + const svc_oidc = req.services.get('oidc'); + const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('login'); + const preamble = await oidcCallbackPreamble_(req, res, callbackRedirectUri); + if ( ! preamble ) return; + const { provider, userinfo, stateDecoded } = preamble; + const user = await svc_oidc.findUserByProviderSub(provider, userinfo.sub); + if ( ! user ) { + return res.status(400).send('No account found. Sign up first.'); + } + if ( user.suspended ) { + return res.status(401).send('This account is suspended.'); + } + return await finishOidcSuccess_(req, res, user, stateDecoded); +}); + +// GET /auth/oidc/callback/signup - signup only: create new account or abort. Never logs in to existing account. +router.get('/auth/oidc/callback/signup', async (req, res) => { + if ( require('../../helpers').subdomain(req) !== '' ) { + return res.status(404).end(); + } + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('login') ) { + return res.status(429).send('Too many requests.'); } const svc_oidc = req.services.get('oidc'); - const stateDecoded = svc_oidc.verifyState(state); - if ( !stateDecoded || !stateDecoded.provider ) { - return res.status(400).send('Invalid or expired state.'); + const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('signup'); + const preamble = await oidcCallbackPreamble_(req, res, callbackRedirectUri); + if ( ! preamble ) return; + const { provider, userinfo, stateDecoded } = preamble; + const existingUser = await svc_oidc.findUserByProviderSub(provider, userinfo.sub); + if ( existingUser ) { + return res.status(400).send('Account already exists. Log in instead.'); } - const provider = stateDecoded.provider; - const redirectUri = `${config.api_base_url}/auth/oidc/callback`; - const tokens = await svc_oidc.exchangeCodeForTokens(provider, code, redirectUri); - if ( !tokens || !tokens.access_token ) { - return res.status(401).send('Token exchange failed.'); + const outcome = await svc_oidc.createUserFromOIDC(provider, userinfo); + if ( outcome.failed ) { + console.log('it looks like the outcome failed...'); + return res.status(400).send(outcome.userMessage); } - const userinfo = await svc_oidc.getUserInfo(provider, tokens.access_token); - if ( !userinfo || !userinfo.sub ) { - return res.status(401).send('Could not get user info.'); - } - let user = await svc_oidc.findUserByProviderSub(provider, userinfo.sub); - if ( user ) { - if ( user.suspended ) { - return res.status(401).send('This account is suspended.'); - } - return await complete_({ req, res, user }); - } - user = await svc_oidc.createUserFromOIDC(provider, userinfo); - if ( ! user ) { - return res.status(400).send('Email already registered. Please log in with your password and link your Google account, or use a different email.'); - } - const accept = req.headers.accept || ''; - const wantsRedirect = accept.includes('text/html'); - if ( wantsRedirect ) { - const svc_auth = req.services.get('auth'); - const { token } = await svc_auth.create_session_token(user, { req }); - res.cookie(config.cookie_name, token, { - sameSite: 'none', - secure: true, - httpOnly: true, - }); - let target = stateDecoded.redirect_uri || config.origin || '/'; - const origin = config.origin || ''; - if ( target && origin && !target.startsWith(origin) ) { - target = origin; - } - return res.redirect(302, target); - } - return await complete_({ req, res, user }); + const user = await get_user({ id: outcome.infoObject.user_id }); + console.log('got user????', user); + return await finishOidcSuccess_(req, res, user, stateDecoded); }); module.exports = router; diff --git a/src/backend/src/routers/login.js b/src/backend/src/routers/login.js index 6aac43f51..21948e518 100644 --- a/src/backend/src/routers/login.js +++ b/src/backend/src/routers/login.js @@ -26,21 +26,21 @@ const { requireCaptcha } = require('../modules/captcha/middleware/captcha-middle const complete_ = async ({ req, res, user }) => { const svc_auth = req.services.get('auth'); - const { token } = await svc_auth.create_session_token(user, { req }); + const { session, token: session_token } = await svc_auth.create_session_token(user, { req }); + const gui_token = svc_auth.create_gui_token(user, session); - //set cookie - // res.cookie(config.cookie_name, token); - res.cookie(config.cookie_name, token, { + // HTTP-only cookie gets session token (cookie-based requests have hasHttpPowers) + res.cookie(config.cookie_name, session_token, { sameSite: 'none', secure: true, httpOnly: true, }); - // send response + // response body: GUI token only (client never gets session token) return res.send({ proceed: true, next_step: 'complete', - token: token, + token: gui_token, user: { username: user.username, uuid: user.uuid, diff --git a/src/backend/src/routers/logout.js b/src/backend/src/routers/logout.js index 84cbe275b..da3ca586a 100644 --- a/src/backend/src/routers/logout.js +++ b/src/backend/src/routers/logout.js @@ -51,7 +51,9 @@ router.post('/logout', auth, express.json(), async (req, res, next) => { //--------------------------------------------------------- // DANGER ZONE: delete temp user and all its data //--------------------------------------------------------- + console.log('wait... what are these?', req.user.password, req.user.email); if ( req.user.password === null && req.user.email === null ) { + console.log('ACTUALLY DELETING A USER'); const { deleteUser } = require('../helpers'); deleteUser(req.user.id); } diff --git a/src/backend/src/routers/save_account.js b/src/backend/src/routers/save_account.js index 5d4cb342c..32471dc55 100644 --- a/src/backend/src/routers/save_account.js +++ b/src/backend/src/routers/save_account.js @@ -208,9 +208,10 @@ router.post('/save_account', auth, express.json(), async (req, res, next) => { } } - // create token for login + // create token for login: session token for cookie, GUI token for client const svc_auth = req.services.get('auth'); - const { token } = await svc_auth.create_session_token(req.user, { req }); + const { session, token: session_token } = await svc_auth.create_session_token(req.user, { req }); + const gui_token = svc_auth.create_gui_token(req.user, session); // user id // todo if pseudo user, assign directly no need to do another DB lookup @@ -220,8 +221,8 @@ router.post('/save_account', auth, express.json(), async (req, res, next) => { // todo send LINK-based verification email - //set cookie - res.cookie(config.cookie_name, token); + // HTTP-only cookie gets session token (cookie-based requests have hasHttpPowers) + res.cookie(config.cookie_name, session_token); { const svc_event = req.services.get('event'); @@ -230,7 +231,7 @@ router.post('/save_account', auth, express.json(), async (req, res, next) => { // return results return res.send({ - token: token, + token: gui_token, user: { username: user.username, uuid: user.uuid, diff --git a/src/backend/src/routers/signup.js b/src/backend/src/routers/signup.js index 0e96ee4e2..1bf9e4499 100644 --- a/src/backend/src/routers/signup.js +++ b/src/backend/src/routers/signup.js @@ -421,11 +421,12 @@ module.exports = eggspress(['/signup'], { const [user] = await db.pread('SELECT * FROM `user` WHERE `id` = ? LIMIT 1', [user_id]); - // create token for login - const { token } = await svc_auth.create_session_token(user, { + // create token for login: session token for cookie, GUI token for client + const { session, token: session_token } = await svc_auth.create_session_token(user, { req, }); - // jwt.sign({uuid: user_uuid}, config.jwt_secret); + const gui_token = svc_auth.create_gui_token(user, session); + // jwt.sign({uuid: user_uuid}, config.jwt_secret); //------------------------------------------------------------- // email confirmation @@ -457,8 +458,8 @@ module.exports = eggspress(['/signup'], { const svc_user = Context.get('services').get('user'); await svc_user.generate_default_fsentries({ user }); - //set cookie - res.cookie(config.cookie_name, token, { + // HTTP-only cookie gets session token (cookie-based requests have hasHttpPowers) + res.cookie(config.cookie_name, session_token, { sameSite: 'none', secure: true, httpOnly: true, @@ -472,7 +473,7 @@ module.exports = eggspress(['/signup'], { // return results return res.send({ - token: token, + token: gui_token, user: { username: user.username, uuid: user.uuid, diff --git a/src/backend/src/routers/signup_create_new_user.js b/src/backend/src/routers/signup_create_new_user.js new file mode 100644 index 000000000..fa577953a --- /dev/null +++ b/src/backend/src/routers/signup_create_new_user.js @@ -0,0 +1,127 @@ +/* + * 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 . + */ +'use strict'; +const config = require('../config'); +const { DB_WRITE } = require('../services/database/consts'); +const { generate_identifier } = require('../util/identifier'); +const { v4: uuidv4 } = require('uuid'); + +/** + * Create a new user for signup. Common behavior shared by POST /signup and OIDC signup. + * Form-signup path is still handled in signup.js; this handles OIDC and will support form signup after refactor. + * + * @param {object} services - Backend services (from req.services) + * @param {object} options - Creation options. For OIDC: { providerId, userinfo }. For form signup: TBD (to be refactored from signup.js). + * @returns {Promise} The created user, or null on failure (e.g. email already registered). + */ +async function signup_create_new_user (services, options) { + const { providerId, userinfo } = options; + if ( !providerId || !userinfo ) { + // Form signup: to be refactored from signup.js; not implemented here yet. + return null; + } + + const db = await services.get('database').get(DB_WRITE, 'auth'); + const svc_group = services.get('group'); + const svc_user = services.get('user'); + const svc_oidc = services.get('oidc'); + if ( ! svc_oidc ) return null; + + const claims = userinfo; + let username = (claims.name || claims.email || '').toString().trim(); + if ( username ) { + username = username.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, ''); + if ( username.length > 45 ) username = username.slice(0, 45); + } + if ( !username || !/^\w+$/.test(username) ) { + let candidate; + do { + candidate = generate_identifier(); + const [r] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [candidate]); + if ( ! r ) username = candidate; + } while ( !username ); + } else { + const [existing] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [username]); + if ( existing ) { + let suffix = 1; + while ( true ) { + const candidate = `${username}${suffix}`; + const [r] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [candidate]); + if ( ! r ) { + username = candidate; break; + } + suffix++; + } + } + } + + const email = (claims.email || '').toString().trim() || null; + const clean_email = email ? email.toLowerCase().trim() : null; + if ( clean_email ) { + const [existingEmail] = await db.pread('SELECT 1 FROM user WHERE clean_email = ? LIMIT 1', [clean_email]); + if ( existingEmail ) { + return null; // email already registered; caller should return error + } + } + + const user_uuid = uuidv4(); + const email_confirm_code = String(Math.floor(100000 + Math.random() * 900000)); + const email_confirm_token = uuidv4(); + + await db.write(`INSERT INTO user ( + username, email, clean_email, password, uuid, referrer, + email_confirm_code, email_confirm_token, free_storage, + referred_by, email_confirmed, requires_email_confirmation + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + username, + email, + clean_email, + null, + user_uuid, + null, + email_confirm_code, + email_confirm_token, + config.storage_capacity, + null, + 1, + 0, + ]); + const [inserted] = await db.pread('SELECT id FROM user WHERE uuid = ? LIMIT 1', [user_uuid]); + const user_id = inserted.id; + + await svc_oidc.linkProviderToUser(user_id, providerId, claims.sub, null); + + await svc_group.add_users({ + uid: config.default_user_group, + users: [username], + }); + + const [user] = await db.pread('SELECT * FROM user WHERE id = ? LIMIT 1', [user_id]); + if ( user && user.metadata && typeof user.metadata === 'string' ) { + user.metadata = JSON.parse(user.metadata); + } else if ( user && !user.metadata ) { + user.metadata = {}; + } + await svc_user.generate_default_fsentries({ user }); + + return user; +} + +module.exports = signup_create_new_user; diff --git a/src/backend/src/services/BaseService.d.ts b/src/backend/src/services/BaseService.d.ts index 65554c240..416bc3a7b 100644 --- a/src/backend/src/services/BaseService.d.ts +++ b/src/backend/src/services/BaseService.d.ts @@ -1,9 +1,15 @@ -import type { ServerHealthService } from '../modules/core/ServerHealthService'; -import { SqliteDatabaseAccessService } from './database/SqliteDatabaseAccessService'; -import { MeteringServiceWrapper } from './MeteringService/MeteringServiceWrapper.mjs'; import { DDBClient } from '../clients/dynamodb/DDBClient'; import { DynamoKVStore } from '../clients/dynamodb/DynamoKVStore/DynamoKVStore'; +import type { ServerHealthService } from '../modules/core/ServerHealthService'; +import { GroupService } from './auth/GroupService'; +import SignupService from './auth/SignupService'; +import { CleanEmailService } from './CleanEmailService'; +import { SqliteDatabaseAccessService } from './database/SqliteDatabaseAccessService'; +import { EventService } from './EventService'; +import { FeatureFlagService } from './FeatureFlagService'; +import { MeteringServiceWrapper } from './MeteringService/MeteringServiceWrapper.mjs'; import type { SUService } from './SUService'; +import { UserService } from './UserService'; export interface ServiceResources { services: { @@ -13,7 +19,13 @@ export interface ServiceResources { get (name: 'server-health'): ServerHealthService; get (name: 'su'): SUService; get (name: 'dynamo'): DDBClient; - get (name: string): any; + get (name: 'user'): UserService; + get (name: 'event'): EventService; + get (name: 'signup'): SignupService; + get (name: 'group'): GroupService; + get (name: 'feature-flag'): FeatureFlagService; + get (name: 'clean-email'): CleanEmailService; + get (name: string): unknown; }; config: Record & { services?: Record; server_id?: string }; name?: string; diff --git a/src/backend/src/services/FeatureFlagService.js b/src/backend/src/services/FeatureFlagService.js index 6424cf5cb..03a90c4be 100644 --- a/src/backend/src/services/FeatureFlagService.js +++ b/src/backend/src/services/FeatureFlagService.js @@ -70,10 +70,12 @@ class FeatureFlagService extends BaseService { /** * checks is a feature flag is enabled for the current user - * @return {boolean} - true if the feature flag is enabled, false otherwise + * @return {boolean} true if the feature flag is enabled, false otherwise * - * Usage: - * check({ actor }, 'flag-name') + * @example with a specified actor + * check({ actor }, 'flag-name'); + * @example with actor in context + * check('flag-name'); */ async check (...a) { // allows binding call with multiple options objects; diff --git a/src/backend/src/services/UserService.js b/src/backend/src/services/UserService.js index 217505c32..c9b4de12d 100644 --- a/src/backend/src/services/UserService.js +++ b/src/backend/src/services/UserService.js @@ -22,6 +22,9 @@ const { invalidate_cached_user } = require('../helpers'); const BaseService = require('./BaseService'); const { DB_WRITE } = require('./database/consts'); +/** + * Lorem ipsum dolor sit amet + */ class UserService extends BaseService { static MODULES = { uuidv4: require('uuid').v4, @@ -54,7 +57,9 @@ class UserService extends BaseService { return this.dir_system; } - // used to be called: generate_system_fsentries + /** + * This used to be called `generate_system_fsentries` + */ async generate_default_fsentries ({ user }) { // Note: The comment below is outdated as we now do parallel writes for diff --git a/src/backend/src/services/auth/Actor.d.ts b/src/backend/src/services/auth/Actor.d.ts index 55f6790e9..1b2c913a0 100644 --- a/src/backend/src/services/auth/Actor.d.ts +++ b/src/backend/src/services/auth/Actor.d.ts @@ -12,8 +12,10 @@ export class SystemActorType { } export class UserActorType { - constructor (params: { user: IUser }); + constructor (params: { user: IUser; session?: { uuid: string }; hasHttpPowers?: boolean }); user: IUser; + /** When true, this actor can access user-protected HTTP endpoints (e.g. change password). GUI tokens set this false. */ + hasHttpPowers: boolean; get uid (): string; get_related_type (type_class: unknown): UserActorType; } diff --git a/src/backend/src/services/auth/Actor.js b/src/backend/src/services/auth/Actor.js index e8d624594..0feeb7b66 100644 --- a/src/backend/src/services/auth/Actor.js +++ b/src/backend/src/services/auth/Actor.js @@ -16,12 +16,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import { AdvancedBase } from '../../../../putility/index.js'; -import { Context } from '../../util/context.js'; -import { get_user, get_app } from '../../helpers.js'; -import * as config from '../../config.js'; -import { v5 as uuidv5 } from 'uuid'; import crypto from 'crypto'; +import { v5 as uuidv5 } from 'uuid'; +import { AdvancedBase } from '../../../../putility/index.js'; +import * as config from '../../config.js'; +import { get_app, get_user } from '../../helpers.js'; +import { Context } from '../../util/context.js'; // TODO: add these to configuration; production deployments should change these! const PRIVATE_UID_NAMESPACE = config.private_uid_namespace @@ -222,6 +222,13 @@ export class Actor extends AdvancedBase { * user actors and define how they relate to other types of actors within the system. */ export class UserActorType extends ActorType { + constructor (o) { + super(o); + if ( this.hasHttpPowers === undefined ) { + this.hasHttpPowers = false; + } + } + /** * Gets the unique identifier for the user actor. * diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index bfc5c8d2d..91b6ad0a4 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -107,6 +107,32 @@ class AuthService extends BaseService { const actor_type = new UserActorType({ user, session: session.uuid, + hasHttpPowers: true, + }); + + return new Actor({ + user_uid: decoded.user_uid, + type: actor_type, + }); + } + + if ( decoded.type === 'gui' ) { + const session = await this.get_session_(decoded.uuid); + + if ( ! session ) { + throw APIError.create('token_auth_failed'); + } + + const user = await get_user({ uuid: decoded.user_uid }); + + if ( ! user ) { + throw APIError.create('user_not_found'); + } + + const actor_type = new UserActorType({ + user, + session: session.uuid, + hasHttpPowers: false, }); return new Actor({ @@ -310,6 +336,25 @@ class AuthService extends BaseService { return { session, token }; } + /** + * Creates a GUI token bound to the same session as the given session object. + * GUI tokens create a UserActorType with hasHttpPowers false, so they cannot + * access user-protected HTTP endpoints (e.g. change password). The GUI receives + * only this token, not the full session token. + * + * @param {*} user - User object (must have .uuid). + * @param {{ uuid: string }} session - Session object (must have .uuid). + * @returns {string} JWT GUI token. + */ + create_gui_token (user, session) { + return this.modules.jwt.sign({ + type: 'gui', + version: '0.0.0', + uuid: session.uuid, + user_uid: user.uuid, + }, this.global_config.jwt_secret); + } + /** * This method checks if the provided session token is valid and returns the associated user and token. * If the token is not a valid session token or it does not exist in the database, it returns an empty object. @@ -323,7 +368,7 @@ class AuthService extends BaseService { console.log('\x1B[36;1mDECODED SESSION', decoded); - if ( decoded.type && decoded.type !== 'session' ) { + if ( decoded.type && decoded.type !== 'session' && decoded.type !== 'gui' ) { return {}; } @@ -343,19 +388,24 @@ class AuthService extends BaseService { return {}; } - // Return the session - return { user, token: cur_token }; + // Return GUI token to client (if they sent session token, exchange for GUI token) + const gui_token = decoded.type === 'gui' + ? cur_token + : this.create_gui_token(user, session); + return { user, token: gui_token }; } this.log.info('UPGRADING SESSION'); // Upgrade legacy token // TODO: phase this out - const { session, token } = await this.create_session_token(user, meta); + const { session, token: session_token } = await this.create_session_token(user, meta); + const gui_token = this.create_gui_token(user, session); const actor_type = new UserActorType({ user, session, + hasHttpPowers: true, }); const actor = new Actor({ @@ -363,7 +413,8 @@ class AuthService extends BaseService { type: actor_type, }); - return { actor, user, token }; + // token = GUI token for client (response body); session_token = for HTTP-only cookie + return { actor, user, token: gui_token, session_token }; } /** @@ -375,7 +426,7 @@ class AuthService extends BaseService { async remove_session_by_token (token) { const decoded = this.modules.jwt.verify(token, this.global_config.jwt_secret); - if ( decoded.type !== 'session' ) { + if ( decoded.type !== 'session' && decoded.type !== 'gui' ) { return; } @@ -469,12 +520,12 @@ class AuthService extends BaseService { } else { token_uid = tokenOrUuid; } - /* eslint-disable */ + await this.db.write( 'DELETE FROM `access_token_permissions` WHERE `token_uid` = ?', [token_uid], ); - /* eslint-enable */ + const svc_permission = this.services.get('permission'); svc_permission.invalidate_permission_scan_cache_for_access_token(token_uid); } @@ -610,6 +661,41 @@ class AuthService extends BaseService { return null; } } + + /** + * Registers GET /get-gui-token. Must be called from the GUI origin (no api. subdomain) + * so the HTTP-only session cookie is sent. Returns the GUI token for use in Authorization headers. + */ + ['__on_install.routes'] () { + const { app } = this.services.get('web-server'); + const config = require('../../config'); + const { subdomain } = require('../../helpers'); + const configurable_auth = require('../../middleware/configurable_auth'); + const { Endpoint } = require('../../util/expressutil'); + const svc_auth = this; + + Endpoint({ + route: '/get-gui-token', + methods: ['GET'], + mw: [configurable_auth()], + handler: async (req, res) => { + if ( ! req.user ) { + return res.status(401).json({}); + } + + const actor = Context.get('actor'); + if ( ! (actor.type instanceof UserActorType) ) { + return res.status(403).json({}); + } + if ( ! actor.type.session ) { + return res.status(400).json({ error: 'No session bound to this actor' }); + } + + const gui_token = svc_auth.create_gui_token(actor.type.user, { uuid: actor.type.session }); + return res.json({ token: gui_token }); + }, + }).attach(app); + } } module.exports = { diff --git a/src/backend/src/services/auth/OIDCService.js b/src/backend/src/services/auth/OIDCService.js index e1029a429..c5fba6318 100644 --- a/src/backend/src/services/auth/OIDCService.js +++ b/src/backend/src/services/auth/OIDCService.js @@ -17,21 +17,33 @@ * along with this program. If not, see . */ 'use strict'; -const BaseService = require('../BaseService'); -const { DB_WRITE } = require('../database/consts'); -const { generate_identifier } = require('../../util/identifier'); +import jwt from 'jsonwebtoken'; +import { username_exists } from '../../helpers.js'; +import { generate_identifier } from '../../util/identifier.js'; +import BaseService from '../BaseService.js'; +import { DB_WRITE } from '../database/consts.js'; const GOOGLE_DISCOVERY_URL = 'https://accounts.google.com/.well-known/openid-configuration'; const GOOGLE_SCOPES = 'openid email profile'; const STATE_EXPIRY_SEC = 600; // 10 minutes +const VALID_OIDC_FLOWS = ['login', 'signup']; + +async function generate_random_username () { + let username; + do { + username = generate_identifier(); + } while ( await username_exists(username) ); + return username; +} + /** * OIDC/OAuth2 service for sign-in with Google (and extensible to other providers). * Uses config.oidc.providers only; no environment variables. */ -class OIDCService extends BaseService { +export class OIDCService extends BaseService { static MODULES = { - jwt: require('jsonwebtoken'), + jwt, }; async _init () { @@ -86,12 +98,25 @@ class OIDCService extends BaseService { } /** - * Build authorization URL for the provider. redirect_uri is our callback URL. + * Return the OAuth callback URL for a given flow. Structure: /auth/oidc/callback/ + * @param {string} flow - e.g. 'login' or 'signup' + * @returns {string|null} Full callback URL, or null if flow is invalid */ - async getAuthorizationUrl (providerId, state, redirectUri) { + getCallbackUrlForFlow (flow) { + if ( !flow || !VALID_OIDC_FLOWS.includes(flow) ) return null; + const base = this.global_config.origin || ''; + const callback_url = `${base.replace(/\/$/, '')}/auth/oidc/callback/${flow}`; + this.log.noticeme('CALLBACK URL???', { callback_url }); + return callback_url; + } + + /** + * Build authorization URL for the provider. Callback URL is /auth/oidc/callback/ when flow is provided. + */ + async getAuthorizationUrl (providerId, state, flow) { const config = await this.getProviderConfig(providerId); if ( ! config ) return null; - const base = redirectUri ?? `${this.global_config.api_base_url}/auth/oidc/callback`; + const base = this.getCallbackUrlForFlow(flow) ?? `${this.global_config.api_base_url}/auth/oidc/callback`; const params = new URLSearchParams({ client_id: config.client_id, redirect_uri: base, @@ -120,7 +145,7 @@ class OIDCService extends BaseService { } /** - * Exchange authorization code for tokens. + * Exchange authorization code for tokens. redirectUri must match the URL used in getAuthorizationUrl (e.g. /auth/oidc/callback/:flow). */ async exchangeCodeForTokens (providerId, code, redirectUri) { const config = await this.getProviderConfig(providerId); @@ -187,91 +212,23 @@ class OIDCService extends BaseService { } /** - * Create a new Puter user from OIDC claims and link the provider. Reuses signup patterns (groups, default fs). + * Create a new Puter user from OIDC claims and link the provider. Delegates to signup_create_new_user. */ async createUserFromOIDC (providerId, claims) { - const db = this.db; - const svc_group = this.services.get('group'); - const svc_user = this.services.get('user'); - const { v4: uuidv4 } = require('uuid'); - - let username = (claims.name || claims.email || '').toString().trim(); - if ( username ) { - username = username.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, ''); - if ( username.length > 45 ) username = username.slice(0, 45); - } - if ( !username || !/^\w+$/.test(username) ) { - let candidate; - do { - candidate = generate_identifier(); - const [r] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [candidate]); - if ( ! r ) username = candidate; - } while ( !username ); - } else { - const [existing] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [username]); - if ( existing ) { - let suffix = 1; - while ( true ) { - const candidate = `${username}${suffix}`; - const [r] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [candidate]); - if ( ! r ) { - username = candidate; break; - } - suffix++; - } - } - } - - const email = (claims.email || '').toString().trim() || null; - const clean_email = email ? email.toLowerCase().trim() : null; - if ( clean_email ) { - const [existingEmail] = await db.pread('SELECT 1 FROM user WHERE clean_email = ? LIMIT 1', [clean_email]); - if ( existingEmail ) { - return null; // email already registered; caller should return error - } - } - const user_uuid = uuidv4(); - const email_confirm_code = String(Math.floor(100000 + Math.random() * 900000)); - const email_confirm_token = uuidv4(); - - await db.write(`INSERT INTO user ( - username, email, clean_email, password, uuid, referrer, - email_confirm_code, email_confirm_token, free_storage, - referred_by, email_confirmed, requires_email_confirmation - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - username, - email, - clean_email, - null, - user_uuid, - null, - email_confirm_code, - email_confirm_token, - this.global_config.storage_capacity, - null, - 1, - 0, - ]); - const [inserted] = await db.pread('SELECT id FROM user WHERE uuid = ? LIMIT 1', [user_uuid]); - const user_id = inserted.id; - - await this.linkProviderToUser(user_id, providerId, claims.sub, null); - - await svc_group.add_users({ - uid: this.global_config.default_user_group, - users: [username], + const svc_signup = this.services.get('signup'); + const outcome = await svc_signup.create_new_user({ + username: await generate_random_username(), + email: claims?.email ?? null, + password: null, + oidc_only: true, }); - - const [user] = await db.pread('SELECT * FROM user WHERE id = ? LIMIT 1', [user_id]); - if ( user && user.metadata && typeof user.metadata === 'string' ) { - user.metadata = JSON.parse(user.metadata); - } else if ( user && !user.metadata ) { - user.metadata = {}; + const { user_id } = outcome.infoObject; + console.log('user_id?', user_id); + if ( outcome.success ) + { + await this.linkProviderToUser(user_id, providerId, claims.sub, null); } - await svc_user.generate_default_fsentries({ user }); - - return user; + return outcome; } /** @@ -287,5 +244,3 @@ class OIDCService extends BaseService { return ids; } } - -module.exports = { OIDCService }; diff --git a/src/backend/src/services/auth/SignupService.js b/src/backend/src/services/auth/SignupService.js new file mode 100644 index 000000000..53e5766c4 --- /dev/null +++ b/src/backend/src/services/auth/SignupService.js @@ -0,0 +1,270 @@ +//@ts-check +import bcrypt from 'bcrypt'; +import { v4 as uuidv4 } from 'uuid'; +import { generate_random_username, send_email_verification_code, send_email_verification_token, username_exists } from '../../helpers.js'; +import { OutcomeObject } from '../../util/outcomeutil.js'; +import { validate_nonEmpty_string } from '../../util/validutil.js'; +import BaseService from '../BaseService.js'; +import { DB_WRITE } from '../database/consts.js'; + +export class CreatedUserOutcome { + /** + * @type {number|null} + */ + user_id = null; +} + +export class SignupService extends BaseService { + /** + * Creates a new user. + * @async + * @param {object} params - The parameters for creating a new user. + * @param {object} [params.req] - The request object (if applicable). + * @param {boolean} [params.temporary] - Whether the user is a temporary user. + * @param {boolean} [params.oidc_only] - Whether the user created with OIDC + * @param {boolean} [params.send_confirmation_code] - Whether to send a confirmation code instead of a token by email + * @param {string|null} params.username - The username of the user. + * @param {string|null} params.email - The email of the user. + * @param {string|null} params.password - The password of the user. + * @returns {Promise>} The outcome of the user creation. + */ + async create_new_user ({ + req, + temporary = false, + oidc_only = false, + send_confirmation_code = false, + username = null, + email = null, + password = null, + }) { + const outcome = new OutcomeObject(new CreatedUserOutcome()); + + let raw_email = email; + + if ( ! username ) { + throw new TypeError('username is a required parameter of create_new_user'); + } + if ( !temporary && !validate_nonEmpty_string(email) ) { + throw new TypeError('email is a required parameter of create_new_user'); + } + + // Temp users get default values; they cannot have emails or passwords + if ( temporary ) { + username = username ?? await generate_random_username(); + email = email ?? `${username}@nonexis.com`; + password = 'login-is-not-enabled'; // arbitrary, but accurate + } + + // Some installations of Puter are configured to disable + // signup or temporary users. In these cases, we will specify + // a failure message and abort creating a user. + { + const svc_featureFlag = this.services.get('feature-flag'); + const is_temp_users_disabled = + await svc_featureFlag.check('temp-users-disabled'); + const is_user_signup_disabled = + await svc_featureFlag.check('user-signup-disabled'); + + if ( is_user_signup_disabled && is_temp_users_disabled ) { + return outcome.fail( + 'User signup and Temporary users are disabled.', + 'signup.signup_and_temp_users_disabled', + ); + } + + if ( temporary && is_temp_users_disabled ) { + return outcome.fail( + 'Temporary users are disabled.', + 'signup.temp_users_disabled', + ); + } + + if ( !temporary && is_user_signup_disabled ) { + return outcome.fail( + 'User signup is disabled.', + 'signup.user_signup_disabled', + ); + } + } + + // Emit the `puter.signup` event + // NOTICE: conditional early return + { + const svc_event = this.services.get('event'); + const event = { allow: true, outcome }; + + if ( req ) { + event.ip = req.headers?.['x-forwarded-for'] || + req.connection?.remoteAddress; + event.user_agent = req.headers?.['user-agent']; + event.body = req.body; + } + + await svc_event.emit('puter.signup', event); + + if ( ! event.allow ) { + outcome.log('disallowed by a puter.signup listener'); + return outcome; + } + } + + if ( await username_exists(username) ) { + return outcome.fail( + 'Username already exists', + 'username_already_exists', + ); + } + + // These checks are required for non-temporary users + if ( ! temporary ) { + const db = this.services.get('database').get(DB_WRITE, 'create-user:not-temp-checks'); + const svc_cleanEmail = this.services.get('clean-email'); + raw_email = email; + + if ( ! email ) { + return outcome.fail( + 'An email address is required', + 'email_required', + ); + } + + email = svc_cleanEmail.clean(email); + if ( ! await svc_cleanEmail.validate(email) ) { + return outcome.fail( + 'This email does not seem to be valid', + 'email_invalid', + ); + } + + let rows2 = await db.read(`SELECT EXISTS( + SELECT 1 FROM user WHERE (email=? OR clean_email=?) AND email_confirmed=1 AND password IS NOT NULL + ) AS email_exists`, [raw_email, email]); + if ( rows2[0].email_exists ) + { + return outcome.fail( + 'Email is already verified for another account', + 'email_already_exists', + ); + } + } + + // TODO: this is where referral goes. We might drop + // referral, so I'm leaving it out here for now. + + const user_uuid = uuidv4(); + const email_confirm_token = uuidv4(); + // TODO: `Math.random()` is not crypto-secure + const email_confirm_code = `${Math.floor(100000 + Math.random() * 900000)}`; + + const audit_metadata = {}; + if ( req ) { + audit_metadata.ip = req.connection.remoteAddress; + audit_metadata.ip_fwd = req.headers['x-forwarded-for']; + audit_metadata.user_agent = req.headers['user-agent']; + audit_metadata.origin = req.headers['origin']; + audit_metadata.server = this.global_config.server_id; + } + + { + const db = this.services.get('database').get(DB_WRITE, 'create-user:main-insert'); + + const insert_res = await db.write(`INSERT INTO user + ( + username, email, clean_email, password, uuid, referrer, + email_confirm_code, email_confirm_token, free_storage, + referred_by, audit_metadata, signup_ip, signup_ip_forwarded, + signup_user_agent, signup_origin, signup_server + ) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + // username + username, + // email + temporary ? null : raw_email, + // normalized email + temporary ? null : email, + // password + (temporary || oidc_only) ? null : await bcrypt.hash(password, 8), + // uuid + user_uuid, + // referrer + req?.body?.referrer ?? null, + // email_confirm_code + email_confirm_code, + // email_confirm_token + email_confirm_token, + // free_storage + this.global_config.storage_capacity, + // referred_by + // TODO: we might remove referalls so I'mm leaving out + // the value for the `referred_by` field for now + null, + // audit_metadata + JSON.stringify(audit_metadata), + // signup_ip + req?.connection?.remoteAddress ?? null, + // signup_ip_fwd + req?.headers?.['x-forwarded-for'] ?? null, + // signup_user_agent + req?.headers?.['user-agent'] ?? null, + // signup_origin + req?.headers?.['origin'] ?? null, + // signup_server + this.global_config.server_id ?? null, + ]); + + // record activity (asynchronously) + db.write( + 'UPDATE `user` SET `last_activity_ts` = now() WHERE id=? LIMIT 1', + [insert_res.insertId], + ); + + // TODO: it would be VERY NICE if this was a calculated + // group membership instead of something we store in the DB + const svc_group = this.services.get('group'); + await svc_group.add_users({ + uid: temporary + ? this.global_config.default_temp_group + : this.global_config.default_user_group, + users: [username], + }); + + const user_id = insert_res.insertId; + outcome.infoObject.user_id = user_id; + + const [user] = await db.pread( + 'SELECT * FROM `user` WHERE `id` = ? LIMIT 1', + [user_id]); + + // TODO(???): should user login happen here or by caller? + { + // const { token } = await svc_auth.create_session_token(user, { + // req, + // }); + } + + if ( send_confirmation_code ) { + send_email_verification_code(email_confirm_code, email); + } else { + send_email_verification_token(email_confirm_token, email, user_uuid); + } + + // TODO: This is where sending the referral code would + // usually happen but we might remove referral so I'm + // leaving it out for now. + const svc_user = this.services.get('user'); + await svc_user.generate_default_fsentries({ user }); + + // NOTE: `res.cookie` happens here in @signup.js but this + // should be handled by the caller over here. + + { + const svc_event = this.services.get('event'); + svc_event.emit('user.save_account', { user }); + } + + return outcome.success(); + } + } +} diff --git a/src/backend/src/services/database/BaseDatabaseAccessService.js b/src/backend/src/services/database/BaseDatabaseAccessService.js index 3d427aa37..4e46eec94 100644 --- a/src/backend/src/services/database/BaseDatabaseAccessService.js +++ b/src/backend/src/services/database/BaseDatabaseAccessService.js @@ -58,7 +58,7 @@ class BaseDatabaseAccessService extends BaseService { * * @returns {BaseDatabaseAccessService} The current instance of the service. */ - get () { + get (_accessLevel, _scope) { return this; } diff --git a/src/backend/src/services/web/UserProtectedEndpointsService.js b/src/backend/src/services/web/UserProtectedEndpointsService.js index 245fbe24a..5a269eb1a 100644 --- a/src/backend/src/services/web/UserProtectedEndpointsService.js +++ b/src/backend/src/services/web/UserProtectedEndpointsService.js @@ -74,7 +74,7 @@ class UserProtectedEndpointsService extends BaseService { // Require authenticated session router.use(configurable_auth({ no_options_auth: true })); - // Only allow user sessions, not API tokens for apps + // Only allow user sessions with HTTP powers (session token), not GUI tokens or API tokens router.use((req, res, next) => { if ( req.method === 'OPTIONS' ) return next(); @@ -82,6 +82,9 @@ class UserProtectedEndpointsService extends BaseService { if ( ! (actor.type instanceof UserActorType) ) { return APIError.create('user_tokens_only').write(res); } + if ( ! actor.type.hasHttpPowers ) { + return APIError.create('session_required').write(res); + } next(); }); @@ -97,7 +100,7 @@ class UserProtectedEndpointsService extends BaseService { router.use(async (req, res, next) => { if ( req.method === 'OPTIONS' ) return next(); - if ( req.user.password === null ) { + if ( req.user.password === null && req.user.email === null ) { return APIError.create('temporary_account').write(res); } next(); diff --git a/src/backend/src/util/outcomeutil.js b/src/backend/src/util/outcomeutil.js new file mode 100644 index 000000000..df64a6ccc --- /dev/null +++ b/src/backend/src/util/outcomeutil.js @@ -0,0 +1,32 @@ +export class OutcomeObject { + userMessage = null; + userMessageKey = null; + userMessageFields = {}; + failed = false; + messages = []; + fields = {}; + ended = false; + infoObject; + constructor (infoObject) { + this.failed = true; + this.userMessageFields = {}; + this.infoObject = infoObject; + } + log (text, fields) { + this.messages.push({ text, fields }); + } + fail (message, i18nKey, fields = {}) { + this.userMessage = message; + this.userMessageKey = i18nKey; + this.userMessageFields = fields; + this.ended = true; + this.failed = true; + return this; + } + success () { + this.ended = true; + this.failed = false; + return this; + } +} +//# sourceMappingURL=outcomeutil.js.map \ No newline at end of file diff --git a/src/backend/src/util/outcomeutil.ts b/src/backend/src/util/outcomeutil.ts new file mode 100644 index 000000000..cdc838a82 --- /dev/null +++ b/src/backend/src/util/outcomeutil.ts @@ -0,0 +1,77 @@ +/** + * Represents the outcome of a task that might fail or succeed. + */ +export class OutcomeObject { + /** + * If the task was not successful, this will be the message a user + * sees. + */ + userMessage = null; + + /** + * If the task was not successful, this will be the i18n key for + * the message a user sees. + */ + userMessageKey = null; + + /** + * If the task was not successful, this will be values used for + * a message template that is identified using `userMessageKey`. + */ + userMessageFields = {}; + + /** + * If the task being performed failed + */ + failed = false; + + messages: Record[] = []; + fields = {}; + + /** + * Whether the task being performed has ended, + * either successfully or unsuccessfully. + */ + ended = false; + + infoObject: T; + + constructor (infoObject: T) { + this.failed = true; + this.userMessageFields = {}; + this.infoObject = infoObject; + } + log (text, fields) { + this.messages.push({ text, fields }); + } + + /** + * Records a failure message. + * Returns the outcome object for chaining with a return statement. + * + * @example + * return outcome.fail( + * 'User already exists', + * 'signup.user_already_exists', + * { username: 'john_doe' } + * ); + * + * @param {*} message - message the user sees without i18n + * @param {*} i18nKey - i18n key for the message + * @param {*} fields - fields for i18n-key-identified template + */ + fail (message, i18nKey, fields = {}) { + this.userMessage = message; + this.userMessageKey = i18nKey; + this.userMessageFields = fields; + this.ended = true; + this.failed = true; + return this; + } + + success () { + this.ended = true; + this.failed = false; + return this; + } +} diff --git a/src/backend/src/util/validutil.js b/src/backend/src/util/validutil.js index f992a953b..e90bc7779 100644 --- a/src/backend/src/util/validutil.js +++ b/src/backend/src/util/validutil.js @@ -1,4 +1,4 @@ -const APIError = require("../api/APIError"); +const APIError = require('../api/APIError'); /* * Copyright (C) 2024-present Puter Technologies Inc. @@ -31,7 +31,7 @@ const valid_file_size = v => { const validate_fields = (fields, values) => { // First, check for missing fields (undefined) - const missing_fields = Object.keys(fields).filter(field => ! fields[field].optional && values[field] === undefined); + const missing_fields = Object.keys(fields).filter(field => !fields[field].optional && values[field] === undefined); if ( missing_fields.length > 0 ) { throw APIError.create('fields_missing', null, { keys: missing_fields }); } @@ -54,9 +54,20 @@ const validate_fields = (fields, values) => { })), }); } -} +}; + +const validate_nonEmpty_string = value => { + if ( typeof value !== 'string' ) { + return false; + } + if ( value.length === 0 ) { + return false; + } + return true; +}; module.exports = { valid_file_size, + validate_nonEmpty_string, validate_fields, }; diff --git a/src/gui/src/UI/Settings/UIWindowChangeEmail.js b/src/gui/src/UI/Settings/UIWindowChangeEmail.js index 0fd394b2d..66f58b231 100644 --- a/src/gui/src/UI/Settings/UIWindowChangeEmail.js +++ b/src/gui/src/UI/Settings/UIWindowChangeEmail.js @@ -108,12 +108,12 @@ async function UIWindowChangeEmail (options) { $(el_window).find('.new-email').attr('disabled', true); $.ajax({ - url: `${window.api_origin }/user-protected/change-email`, + url: `${window.gui_origin}/user-protected/change-email`, type: 'POST', async: true, - headers: { - 'Authorization': `Bearer ${window.auth_token}`, - }, + // headers: { + // 'Authorization': `Bearer ${window.auth_token}`, + // }, contentType: 'application/json', data: JSON.stringify({ new_email: new_email, diff --git a/src/gui/src/UI/UIWindowLogin.js b/src/gui/src/UI/UIWindowLogin.js index 8f660f9ec..c944c4b8c 100644 --- a/src/gui/src/UI/UIWindowLogin.js +++ b/src/gui/src/UI/UIWindowLogin.js @@ -154,15 +154,13 @@ async function UIWindowLogin (options) { (async () => { try { - const origin = window.gui_origin || window.location.origin; - const res = await fetch(`${origin}/auth/oidc/providers`); + const res = await fetch(`${window.api_origin}/auth/oidc/providers`); if ( ! res.ok ) return; const data = await res.json(); if ( data.providers && data.providers.includes('google') ) { $(el_window).find('.oidc-providers-wrapper').show(); $(el_window).find('.oidc-google-btn').on('click', function () { - const redirectUri = encodeURIComponent(window.location.origin + (window.location.pathname || '/')); - window.location.href = `${origin}/auth/oidc/google/start?redirect_uri=${redirectUri}`; + window.location.href = `${window.gui_origin}/auth/oidc/google/start?flow=login`; }); } } catch (_) { diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 905fd5a31..61be05e5e 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -162,15 +162,13 @@ function UIWindowSignup (options) { (async () => { try { - const origin = window.gui_origin || window.location.origin; - const res = await fetch(`${origin}/auth/oidc/providers`); + const res = await fetch(`${window.api_origin}/auth/oidc/providers`); if ( ! res.ok ) return; const data = await res.json(); if ( data.providers && data.providers.includes('google') ) { $(el_window).find('.oidc-providers-wrapper').show(); $(el_window).find('.oidc-google-btn').on('click', function () { - const redirectUri = encodeURIComponent(window.location.origin + (window.location.pathname || '/')); - window.location.href = `${origin}/auth/oidc/google/start?redirect_uri=${redirectUri}`; + window.location.href = `${window.gui_origin}/auth/oidc/google/start?flow=signup`; }); } } catch (_) { diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index aba48a669..b634e37a4 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -17,11 +17,14 @@ * along with this program. If not, see . */ +import UIDashboard from './UI/Dashboard/UIDashboard.js'; import UIAlert from './UI/UIAlert.js'; import UIComponentWindow from './UI/UIComponentWindow.js'; import UIDesktop from './UI/UIDesktop.js'; import UIWindow from './UI/UIWindow.js'; +import UIWindowAuthMe from './UI/UIWindowAuthMe.js'; import UIWindowChangeUsername from './UI/UIWindowChangeUsername.js'; +import UIWindowCopyToken from './UI/UIWindowCopyToken.js'; import UIWindowEmailConfirmationRequired from './UI/UIWindowEmailConfirmationRequired.js'; import UIWindowLogin from './UI/UIWindowLogin.js'; import UIWindowLoginInProgress from './UI/UIWindowLoginInProgress.js'; @@ -30,8 +33,6 @@ import UIWindowRequestPermission from './UI/UIWindowRequestPermission.js'; import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js'; import UIWindowSessionList from './UI/UIWindowSessionList.js'; import UIWindowSignup from './UI/UIWindowSignup.js'; -import UIWindowCopyToken from './UI/UIWindowCopyToken.js'; -import UIWindowAuthMe from './UI/UIWindowAuthMe.js'; import { PROCESS_RUNNING } from './definitions.js'; import item_icon from './helpers/item_icon.js'; import update_last_touch_coordinates from './helpers/update_last_touch_coordinates.js'; @@ -49,7 +50,6 @@ import { ProcessService } from './services/ProcessService.js'; import { SettingsService } from './services/SettingsService.js'; import { ThemeService } from './services/ThemeService.js'; import { privacy_aware_path } from './util/desktop.js'; -import UIDashboard from './UI/Dashboard/UIDashboard.js'; const launch_services = async function (options) { // === Services Data Structures === @@ -393,6 +393,21 @@ window.initgui = async function (options) { // Launch services before any UI is rendered await launch_services(options); + // If no token in storage but we have a session cookie (e.g. after OIDC redirect), fetch GUI token + if ( !localStorage.getItem('auth_token') && window.auth_token == null ) { + try { + const r = await fetch(`${window.gui_origin}/get-gui-token`, { credentials: 'include' }); + if ( r.ok ) { + const { token } = await r.json(); + window.auth_token = token; + localStorage.setItem('auth_token', token); + if ( typeof puter !== 'undefined' ) puter.setAuthToken(token, window.api_origin); + } + } catch (e) { + // ignore + } + } + //-------------------------------------------------------------------------------------- // Is attempt_temp_user_creation? // i.e. https://puter.com/?attempt_temp_user_creation=true From 4374281070b4b106f690c132b003e7beba90620b Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:56:16 -0500 Subject: [PATCH 03/39] dev: add re-authentication flow for protect actions When users make sensitive changes to their account they are asked to re-enter their password. This prevents a hijacked session from causing futher damage. Users created with the new OIDC flow do not necessarily have a password set on their account, and they need to also be able to make these changes. While removal of the password entry requirement for these users would solve this problem, it would also make their accounts more vulnerable. To solve this problem while maintaining the same security standard for OIDC users, we need them to confirm via either 2FA or re-authentication via OIDC. Since users aren't required to have 2FA, the re-authentication via OIDC approach is also the minimum viable solution. This commit adds OIDC re-authentication support for all endpoints under UserProtectedEndpointsService, and makes updates to the UIWindowChangeUsername dialog for manual testing. Currently this implementation fails at the final submission to change the username because of a separate issue with the correct authentication token not being set; this is related to the separation of GUI tokens vs http-only tokens. --- extensions/whoami/routes.js | 17 ++ src/backend/src/api/APIError.js | 4 + src/backend/src/routers/auth/oidc.js | 71 +++++++ .../routers/user-protected/change-username.js | 75 ++++++++ .../abuse-prevention/EdgeRateLimitService.js | 4 + src/backend/src/services/auth/OIDCService.js | 2 +- .../web/UserProtectedEndpointsService.js | 69 ++++--- src/gui/src/UI/UIWindowChangeUsername.js | 177 +++++++++++++----- src/gui/src/i18n/translations/en.js | 3 + 9 files changed, 353 insertions(+), 69 deletions(-) create mode 100644 src/backend/src/routers/user-protected/change-username.js diff --git a/extensions/whoami/routes.js b/extensions/whoami/routes.js index 9184ce50e..ad9806677 100644 --- a/extensions/whoami/routes.js +++ b/extensions/whoami/routes.js @@ -76,6 +76,7 @@ extension.get('/whoami', { subdomain: 'api' }, async (req, res, next) => { } } + const oidc_only = req.user.password === null; const details = { username: req.user.username, uuid: req.user.uuid, @@ -88,6 +89,20 @@ extension.get('/whoami', { subdomain: 'api' }, async (req, res, next) => { desktop_bg_color: req.user.desktop_bg_color, desktop_bg_fit: req.user.desktop_bg_fit, is_temp: (req.user.password === null && req.user.email === null), + oidc_only, + ...(oidc_only ? await (async () => { + try { + const svc_oidc = req.services.get('oidc'); + const providers = await svc_oidc.getEnabledProviderIds(); + const origin = (svc_oidc.global_config?.origin || '').replace(/\/$/, ''); + const provider = providers && providers[0]; + return provider + ? { oidc_revalidate_url: `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_id=${req.user.id}` } + : {}; + } catch ( _e ) { + return {}; + } + })() : {}), taskbar_items: await get_taskbar_items(req.user, { ...(req.query.icon_size ? { icon_size: req.query.icon_size } @@ -216,6 +231,7 @@ extension.post('/whoami', { subdomain: 'api' }, async (req, res) => { } } + const oidc_only = req.user.password === null; // send user object res.send(Object.assign({ username: req.user.username, @@ -228,6 +244,7 @@ extension.post('/whoami', { subdomain: 'api' }, async (req, res) => { desktop_bg_color: req.user.desktop_bg_color, desktop_bg_fit: req.user.desktop_bg_fit, is_temp: (req.user.password === null && req.user.email === null), + oidc_only, taskbar_items: await get_taskbar_items(req.user), desktop_items: desktop_items, referral_code: req.user.referral_code, diff --git a/src/backend/src/api/APIError.js b/src/backend/src/api/APIError.js index 496348f64..d8cb3a3e1 100644 --- a/src/backend/src/api/APIError.js +++ b/src/backend/src/api/APIError.js @@ -469,6 +469,10 @@ class APIError { status: 403, message: 'Password does not match.', }, + 'oidc_revalidation_required': { + status: 403, + message: 'Re-validate by signing in with your linked account (e.g. Google).', + }, // Object Mapping 'field_not_allowed_for_create': { diff --git a/src/backend/src/routers/auth/oidc.js b/src/backend/src/routers/auth/oidc.js index 52e5bee30..bea022e23 100644 --- a/src/backend/src/routers/auth/oidc.js +++ b/src/backend/src/routers/auth/oidc.js @@ -20,8 +20,12 @@ const express = require('express'); const router = new express.Router(); const config = require('../../config'); +const jwt = require('jsonwebtoken'); const { get_user } = require('../../helpers'); +const REVALIDATION_COOKIE_NAME = 'puter_revalidation'; +const REVALIDATION_EXPIRY_SEC = 300; // 5 minutes + /** If Accept includes text/html, set session cookie and redirect to app; otherwise send JSON. */ const finishOidcSuccess_ = async (req, res, user, stateDecoded) => { console.log('okay finishOidSuccess_ is happening'); @@ -101,9 +105,18 @@ router.get('/auth/oidc/:provider/start', async (req, res) => { const flowRedirects = { login: config.origin || '/', signup: config.origin || '/', + revalidate: `${(config.origin || '').replace(/\/$/, '')}/auth/revalidate-done`, }; const appRedirectUri = (flow && flowRedirects[flow]) ? flowRedirects[flow] : (config.origin || '/'); const statePayload = { provider, redirect_uri: appRedirectUri }; + if ( flow === 'revalidate' ) { + const user_id = req.query.user_id; + if ( ! user_id ) { + return res.status(400).send('user_id required for revalidate flow.'); + } + statePayload.user_id = Number(user_id); + statePayload.flow = 'revalidate'; + } const state = svc_oidc.signState(statePayload); const url = await svc_oidc.getAuthorizationUrl(provider, state, flow); if ( ! url ) { @@ -164,4 +177,62 @@ router.get('/auth/oidc/callback/signup', async (req, res) => { return await finishOidcSuccess_(req, res, user, stateDecoded); }); +// GET /auth/oidc/callback/revalidate - re-validate identity for protected actions (e.g. change username). Sets short-lived cookie and redirects. +router.get('/auth/oidc/callback/revalidate', async (req, res) => { + if ( require('../../helpers').subdomain(req) !== '' ) { + return res.status(404).end(); + } + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('login') ) { + return res.status(429).send('Too many requests.'); + } + const svc_oidc = req.services.get('oidc'); + const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('revalidate'); + const preamble = await oidcCallbackPreamble_(req, res, callbackRedirectUri); + if ( ! preamble ) return; + const { provider, userinfo, stateDecoded } = preamble; + if ( stateDecoded.flow !== 'revalidate' || stateDecoded.user_id == null ) { + return res.status(400).send('Invalid revalidate state.'); + } + const user = await svc_oidc.findUserByProviderSub(provider, userinfo.sub); + if ( ! user ) { + return res.status(400).send('No account found.'); + } + if ( user.id !== stateDecoded.user_id ) { + return res.status(403).send('Wrong account. Sign in with the account linked to this session.'); + } + const token = jwt.sign({ user_id: user.id, purpose: 'revalidate' }, + config.jwt_secret, + { expiresIn: REVALIDATION_EXPIRY_SEC }); + res.cookie(REVALIDATION_COOKIE_NAME, token, { + sameSite: 'lax', + secure: true, + httpOnly: true, + maxAge: REVALIDATION_EXPIRY_SEC * 1000, + path: '/', + }); + const target = stateDecoded.redirect_uri || `${(config.origin || '').replace(/\/$/, '')}/auth/revalidate-done`; + return res.redirect(302, target); +}); + +// GET /auth/revalidate-done - landing page after OIDC revalidate; posts to opener and closes (for popup flow). +router.get('/auth/revalidate-done', (req, res) => { + if ( require('../../helpers').subdomain(req) !== '' ) { + return res.status(404).end(); + } + const origin = config.origin || ''; + res.set('Content-Type', 'text/html; charset=utf-8'); + res.send(`Re-validated

Re-validated. Closing…

`); +}); + module.exports = router; diff --git a/src/backend/src/routers/user-protected/change-username.js b/src/backend/src/routers/user-protected/change-username.js new file mode 100644 index 000000000..6b22eb83c --- /dev/null +++ b/src/backend/src/routers/user-protected/change-username.js @@ -0,0 +1,75 @@ +/* + * 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 . + */ +const config = require('../../config'); +const APIError = require('../../api/APIError.js'); +const { DB_WRITE } = require('../../services/database/consts'); +const { username_exists, change_username } = require('../../helpers'); +const { Context } = require('../../util/context'); + +module.exports = { + route: '/change-username', + methods: ['POST'], + handler: async (req, res, next) => { + const user = req.user; + const new_username = req.body.new_username; + + if ( ! new_username ) { + throw APIError.create('field_missing', null, { key: 'new_username' }); + } + if ( typeof new_username !== 'string' ) { + throw APIError.create('field_invalid', null, { key: 'new_username', expected: 'a string' }); + } + if ( ! new_username.match(config.username_regex) ) { + throw APIError.create('field_invalid', null, { key: 'new_username', expected: 'letters, numbers, underscore (_)' }); + } + if ( new_username.length > config.username_max_length ) { + throw APIError.create('field_too_long', null, { key: 'new_username', max_length: config.username_max_length }); + } + if ( await username_exists(new_username) ) { + throw APIError.create('username_already_in_use', null, { username: new_username }); + } + + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('change-email-start') ) { + return res.status(429).send('Too many requests.'); + } + + const db = Context.get('services').get('database').get(DB_WRITE, 'auth'); + const rows = await db.read('SELECT COUNT(*) AS `count` FROM `user_update_audit` ' + + `WHERE \`user_id\`=? AND \`reason\`=? AND ${ + db.case({ + mysql: '`created_at` > DATE_SUB(NOW(), INTERVAL 1 MONTH)', + sqlite: "`created_at` > datetime('now', '-1 month')", + })}`, + [ user.id, 'change_username' ]); + + if ( rows[0].count >= 2 ) { + throw APIError.create('too_many_username_changes'); + } + + await db.write('INSERT INTO `user_update_audit` ' + + '(`user_id`, `user_id_keep`, `old_username`, `new_username`, `reason`) ' + + 'VALUES (?, ?, ?, ?, ?)', + [ user.id, user.id, user.username, new_username, 'change_username' ]); + + await change_username(user.id, new_username); + + res.json({}); + }, +}; diff --git a/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js b/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js index 03265e3ef..4f7f38c5e 100644 --- a/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js +++ b/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js @@ -104,6 +104,10 @@ class EdgeRateLimitService extends BaseService { limit: 10, window: HOUR, }, + ['/user-protected/change-username']: { + limit: 10, + window: HOUR, + }, ['/user-protected/disable-2fa']: { limit: 10, window: HOUR, diff --git a/src/backend/src/services/auth/OIDCService.js b/src/backend/src/services/auth/OIDCService.js index c5fba6318..86e6ef98b 100644 --- a/src/backend/src/services/auth/OIDCService.js +++ b/src/backend/src/services/auth/OIDCService.js @@ -27,7 +27,7 @@ const GOOGLE_DISCOVERY_URL = 'https://accounts.google.com/.well-known/openid-con const GOOGLE_SCOPES = 'openid email profile'; const STATE_EXPIRY_SEC = 600; // 10 minutes -const VALID_OIDC_FLOWS = ['login', 'signup']; +const VALID_OIDC_FLOWS = ['login', 'signup', 'revalidate']; async function generate_random_username () { let username; diff --git a/src/backend/src/services/web/UserProtectedEndpointsService.js b/src/backend/src/services/web/UserProtectedEndpointsService.js index 5a269eb1a..98365ee01 100644 --- a/src/backend/src/services/web/UserProtectedEndpointsService.js +++ b/src/backend/src/services/web/UserProtectedEndpointsService.js @@ -24,6 +24,19 @@ const { UserActorType } = require('../auth/Actor'); const { Endpoint } = require('../../util/expressutil'); const APIError = require('../../api/APIError.js'); const configurable_auth = require('../../middleware/configurable_auth.js'); +const config = require('../../config'); +const jwt = require('jsonwebtoken'); + +const REVALIDATION_COOKIE_NAME = 'puter_revalidation'; + +async function revalidateUrlFields_ (svc, req, user) { + const origin = (config.origin || '').replace(/\/$/, ''); + const svc_oidc = req.services.get('oidc'); + const providers = await svc_oidc.getEnabledProviderIds(); + const provider = providers && providers[0]; + if ( ! provider ) return {}; + return { revalidate_url: `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_id=${user.id}` }; +} /** * @class UserProtectedEndpointsService @@ -107,39 +120,53 @@ class UserProtectedEndpointsService extends BaseService { }); /** - * Middleware to validate the provided password against the stored user password. - * - * This method ensures that the user has entered their current password correctly before - * allowing changes to critical account settings. It uses bcrypt for password comparison. - * - * @param {Object} req - Express request object, containing user and password in body. - * @param {Object} res - Express response object for sending back the response. - * @param {Function} next - Callback to pass control to the next middleware or route handler. + * Middleware to validate identity: either password (bcrypt) or a valid OIDC revalidation cookie. + * OIDC-only accounts (user.password === null) must use revalidation; password accounts may use either. */ router.use(async (req, res, next) => { if ( req.method === 'OPTIONS' ) return next(); - if ( ! req.body.password ) { - return (APIError.create('password_required')).write(res); - } - - const bcrypt = (() => { - const require = this.require; - return require('bcrypt'); - })(); - const user = await get_user({ id: req.user.id, force: true }); - const isMatch = await bcrypt.compare(req.body.password, user.password); - if ( ! isMatch ) { - return APIError.create('password_mismatch').write(res); + const revalidationCookie = req.cookies && req.cookies[REVALIDATION_COOKIE_NAME]; + + if ( req.body.password ) { + if ( user.password === null ) { + return (APIError.create('oidc_revalidation_required', null, await revalidateUrlFields_(this, req, user))).write(res); + } + const bcrypt = (() => { + const require = this.require; + return require('bcrypt'); + })(); + const isMatch = await bcrypt.compare(req.body.password, user.password); + if ( ! isMatch ) { + return APIError.create('password_mismatch').write(res); + } + return next(); } - next(); + + if ( revalidationCookie ) { + try { + const payload = jwt.verify(revalidationCookie, config.jwt_secret); + if ( payload.purpose === 'revalidate' && payload.user_id === req.user.id ) { + return next(); + } + } catch ( e ) { + // invalid or expired + } + } + + if ( user.password === null ) { + return (APIError.create('oidc_revalidation_required', null, await revalidateUrlFields_(this, req, user))).write(res); + } + return (APIError.create('password_required')).write(res); }); Endpoint(require('../../routers/user-protected/change-password.js')).attach(router); Endpoint(require('../../routers/user-protected/change-email.js')).attach(router); + Endpoint(require('../../routers/user-protected/change-username.js')).attach(router); + Endpoint(require('../../routers/user-protected/disable-2fa.js')).attach(router); } } diff --git a/src/gui/src/UI/UIWindowChangeUsername.js b/src/gui/src/UI/UIWindowChangeUsername.js index d826cf1ef..8e27757e7 100644 --- a/src/gui/src/UI/UIWindowChangeUsername.js +++ b/src/gui/src/UI/UIWindowChangeUsername.js @@ -17,8 +17,8 @@ * along with this program. If not, see . */ -import UIWindow from './UIWindow.js'; import update_username_in_gui from '../helpers/update_username_in_gui.js'; +import UIWindow from './UIWindow.js'; async function UIWindowChangeUsername (options) { options = options ?? {}; @@ -26,17 +26,23 @@ async function UIWindowChangeUsername (options) { const internal_id = window.uuidv4(); let h = ''; h += '
'; - // error msg h += '
'; - // success msg h += '
'; - // new username h += '
'; h += ``; h += ``; h += '
'; - - // Change Username + h += '
'; + h += ``; + h += '
'; + h += ``; + h += '
'; + h += ''; + h += ''; + h += '
'; h += ``; h += '
'; @@ -63,6 +69,15 @@ async function UIWindowChangeUsername (options) { show_in_taskbar: false, onAppend: function (this_window) { $(this_window).find('.new-username').get(0)?.focus({ preventScroll: true }); + const oidc_only = !!(window.user && window.user.oidc_only); + const authRow = $(this_window).find('.change-username-auth-row'); + if ( oidc_only ) { + authRow.find('.change-username-password-wrap').hide(); + const oidcWrap = authRow.find('.change-username-oidc-wrap').show(); + oidcWrap.find('.change-username-revalidate-btn').text(i18n('revalidate_with_google') || 'Re-validate with Google'); + } else { + authRow.find('.change-username-oidc-wrap').hide(); + } }, window_class: 'window-publishWebsite', body_css: { @@ -74,59 +89,127 @@ async function UIWindowChangeUsername (options) { ...options.window_options, }); - $(el_window).find('.change-username-btn').on('click', function (e) { - // hide previous error/success msg - $(el_window).find('.form-success-msg, .form-success-msg').hide(); + const origin = window.gui_origin || window.api_origin || ''; + const apiUrl = `${origin}/user-protected/change-username`; + let revalidated = false; + $(el_window).find('.change-username-btn').on('click', async function (e) { + $(el_window).find('.form-success-msg, .form-error-msg').hide(); const new_username = $(el_window).find('.new-username').val(); + const password = $(el_window).find('.change-username-password').val(); + const oidc_only = !!(window.user && window.user.oidc_only); if ( ! new_username ) { $(el_window).find('.form-error-msg').html(i18n('all_fields_required')); $(el_window).find('.form-error-msg').fadeIn(); return; } - + if ( oidc_only && !revalidated && !password ) { + $(el_window).find('.change-username-btn').addClass('disabled'); + openRevalidatePopup(null, async (err) => { + if ( err ) { + onError(err.message || 'Re-validation required.'); + return; + } + const res = await doSubmit(); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + if ( res.ok ) onSuccess(); + else onError(data.message || 'Request failed'); + }); + return; + } $(el_window).find('.form-error-msg').hide(); - - // disable button $(el_window).find('.change-username-btn').addClass('disabled'); - // disable input - $(el_window).find('.new-username').attr('disabled', true); + $(el_window).find('.new-username, .change-username-password').attr('disabled', true); - $.ajax({ - url: `${window.api_origin }/change_username`, - type: 'POST', - async: true, - headers: { - 'Authorization': `Bearer ${window.auth_token}`, - }, - contentType: 'application/json', - data: JSON.stringify({ - new_username: new_username, - }), - success: function (data) { - $(el_window).find('.form-success-msg').html(i18n('username_changed')); - $(el_window).find('.form-success-msg').fadeIn(); - $(el_window).find('input').val(''); - // update auth data - update_username_in_gui(new_username); - // update username - window.user.username = new_username; - // enable button - $(el_window).find('.change-username-btn').removeClass('disabled'); - // enable input - $(el_window).find('.new-username').attr('disabled', false); - }, - error: function (err) { - $(el_window).find('.form-error-msg').html(html_encode(err.responseJSON?.message)); - $(el_window).find('.form-error-msg').fadeIn(); - // enable button - $(el_window).find('.change-username-btn').removeClass('disabled'); - // enable input - $(el_window).find('.new-username').attr('disabled', false); - }, - }); + let res = await doSubmit(password); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + + if ( res.ok ) { + onSuccess(); + return; + } + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + openRevalidatePopup(data.revalidate_url, async () => { + const r = await doSubmit(); + if ( r.ok ) onSuccess(); + else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed')); + }); + return; + } + onError(data.message || 'Request failed'); }); + + function openRevalidatePopup (revalidateUrl, onDone) { + const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + if ( ! url ) { + onDone && onDone(new Error('No revalidate URL')); + return null; + } + const hint = $(el_window).find('.change-username-oidc-hint'); + hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); + const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); + const onMessage = (ev) => { + if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; + if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; + + console.log('is this condition working?', ev.origin !== (window.gui_origin || window.location.origin)); + console.log('If TRUE something does not match:', ev.origin, window.gui_origin, window.location.origin); + window.removeEventListener('message', onMessage); + revalidated = true; + hint.hide(); + $(el_window).find('.change-username-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); + $(el_window).find('.change-username-revalidate-btn').hide(); + onDone && onDone(); + }; + window.addEventListener('message', onMessage); + const checkClosed = setInterval(() => { + if ( popup && popup.closed ) { + clearInterval(checkClosed); + window.removeEventListener('message', onMessage); + hint.hide(); + onDone && onDone(new Error('Popup closed')); + } + }, 300); + return popup; + } + + $(el_window).find('.change-username-revalidate-btn').on('click', function () { + openRevalidatePopup(); + }); + + function doSubmit (password) { + const new_username = $(el_window).find('.new-username').val(); + const body = { new_username }; + if ( password !== undefined && password !== '' ) body.password = password; + return fetch(apiUrl, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...(window.auth_token ? { 'Authorization': `Bearer ${window.auth_token}` } : {}), + }, + body: JSON.stringify(body), + }); + } + + function onSuccess () { + const new_username = $(el_window).find('.new-username').val(); + $(el_window).find('.form-success-msg').html(i18n('username_changed')); + $(el_window).find('.form-success-msg').fadeIn(); + $(el_window).find('input').val(''); + update_username_in_gui(new_username); + window.user.username = new_username; + $(el_window).find('.change-username-btn').removeClass('disabled'); + $(el_window).find('.new-username, .change-username-password').attr('disabled', false); + } + + function onError (message) { + $(el_window).find('.form-error-msg').html(html_encode(message)); + $(el_window).find('.form-error-msg').fadeIn(); + $(el_window).find('.change-username-btn').removeClass('disabled'); + $(el_window).find('.new-username, .change-username-password').attr('disabled', false); + } } export default UIWindowChangeUsername; \ No newline at end of file diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index ba9752e0c..babdb5763 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -49,6 +49,9 @@ const en = { change_password: 'Change Password', change_ui_colors: 'Change UI Colors', change_username: 'Change Username', + revalidate_with_google: 'Re-validate with Google', + revalidated: 'Re-validated.', + revalidate_sign_in_popup: 'Sign in with your linked account in the popup.', color_depth: 'Color Depth', clock_visibility: 'Clock Visibility', close: 'Close', From 5d22ee05171b81d56fdf17603c846fcf0e318334 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:23:45 -0500 Subject: [PATCH 04/39] tweak: re-enable re-auth popup closing --- src/backend/src/routers/auth/oidc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/src/routers/auth/oidc.js b/src/backend/src/routers/auth/oidc.js index bea022e23..bf74ad7d9 100644 --- a/src/backend/src/routers/auth/oidc.js +++ b/src/backend/src/routers/auth/oidc.js @@ -227,7 +227,7 @@ router.get('/auth/revalidate-done', (req, res) => { var origin = ${JSON.stringify(origin)}; if (window.opener) { try { window.opener.postMessage({ type: 'puter-revalidate-done' }, origin); } catch (e) {} - // window.close(); + window.close(); } else { document.body.innerHTML = '

Re-validated. You can close this tab.

'; } From d532b3d47b21002cff567c4aa055a5658fb3d8ce Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:10:01 -0500 Subject: [PATCH 05/39] fix(oidc): session token vs gui token issues --- .../src/middleware/configurable_auth.js | 23 ++++++++++++++++++- src/gui/src/UI/Dashboard/TabSecurity.js | 3 ++- src/gui/src/UI/Settings/UITabSecurity.js | 3 ++- src/gui/src/UI/UIWindowChangePassword.js | 5 ++-- src/gui/src/UI/UIWindowChangeUsername.js | 2 +- 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/backend/src/middleware/configurable_auth.js b/src/backend/src/middleware/configurable_auth.js index 364acaf41..49d2e9373 100644 --- a/src/backend/src/middleware/configurable_auth.js +++ b/src/backend/src/middleware/configurable_auth.js @@ -20,6 +20,7 @@ const APIError = require('../api/APIError'); const config = require('../config'); const { LegacyTokenError } = require('../services/auth/AuthService'); const { Context } = require('../util/context'); +const jwt = require('jsonwebtoken'); // The "/whoami" endpoint is a special case where we want to allow // a legacy token to be used for authentication. The "/whoami" @@ -47,7 +48,7 @@ const configurable_auth = options => async (req, res, next) => { const optional = options?.optional; // Request might already have been authed (PreAuthService) - if ( req.actor ) next(); + if ( req.actor ) return next(); // === Getting the Token === // This step came from jwt_auth in src/helpers.js @@ -55,15 +56,18 @@ const configurable_auth = options => async (req, res, next) => { // auth middleware, it makes more sense to put it here. let token; + let tokenSource; // Auth token in body if ( req.body && req.body.auth_token ) { token = req.body.auth_token; + tokenSource = 'body'; } // HTTML Auth header else if ( req.header && req.header('Authorization') && !req.header('Authorization').startsWith('Basic ') && req.header('Authorization') !== 'Bearer' ) { // Bearer with no space is something office does token = req.header('Authorization'); token = token.replace('Bearer ', '').trim(); + tokenSource = 'header'; if ( token === 'undefined' ) { APIError.create('unexpected_undefined', null, { msg: 'The Authorization token cannot be the string "undefined"', @@ -74,16 +78,19 @@ const configurable_auth = options => async (req, res, next) => { else if ( req.cookies && req.cookies[config.cookie_name] ) { token = req.cookies[config.cookie_name]; + tokenSource = 'cookie'; } // Auth token in URL else if ( req.query && req.query.auth_token ) { token = req.query.auth_token; + tokenSource = 'query'; } // Socket else if ( req.handshake && req.handshake.query && req.handshake.query.auth_token ) { token = req.handshake.query.auth_token; + tokenSource = 'socket'; } if ( !token || token.startsWith('Basic ') ) { @@ -110,6 +117,20 @@ const configurable_auth = options => async (req, res, next) => { const services = context.get('services'); const svc_auth = services.get('auth'); + // Debug: log token source and decoded type before creating Actor (for session_required / hasHttpPowers debugging) + if ( process.env.DEBUG ) { + let decodedForLog; + try { + decodedForLog = jwt.decode(token); + } catch ( _ ) { /* ignore */ } + console.log('decodedForLog?', decodedForLog); + const tokenType = decodedForLog && decodedForLog.t != null ? decodedForLog.t : '(no type or invalid jwt)'; + const tokenPreview = typeof token === 'string' && token.length > 20 + ? `${token.slice(0, 12)}...${token.slice(-8)}` + : '(short)'; + console.log(`[configurable_auth] token used for Actor: [${req.url}] source=${tokenSource}, decoded.type=${tokenType}, preview=${tokenPreview}`); + } + let actor; try { actor = await svc_auth.authenticate_from_token(token); diff --git a/src/gui/src/UI/Dashboard/TabSecurity.js b/src/gui/src/UI/Dashboard/TabSecurity.js index fbefd99ae..b9990ba96 100644 --- a/src/gui/src/UI/Dashboard/TabSecurity.js +++ b/src/gui/src/UI/Dashboard/TabSecurity.js @@ -152,10 +152,11 @@ const TabSecurity = { const password_confirm_promise = new TeePromise(); const try_password = async () => { const value = $win.find('.password-entry').val(); + // Do not send Authorization: user-protected endpoints use session cookie (hasHttpPowers) const resp = await fetch(`${window.api_origin}/user-protected/disable-2fa`, { method: 'POST', + credentials: 'include', headers: { - Authorization: `Bearer ${puter.authToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ diff --git a/src/gui/src/UI/Settings/UITabSecurity.js b/src/gui/src/UI/Settings/UITabSecurity.js index 1f961e1e4..af2ca184d 100644 --- a/src/gui/src/UI/Settings/UITabSecurity.js +++ b/src/gui/src/UI/Settings/UITabSecurity.js @@ -86,10 +86,11 @@ export default { const password_confirm_promise = new TeePromise(); const try_password = async () => { const value = password_entry.get('value'); + // No Authorization header: user-protected endpoints use session cookie (hasHttpPowers) const resp = await fetch(`${window.api_origin}/user-protected/disable-2fa`, { method: 'POST', + credentials: 'include', headers: { - Authorization: `Bearer ${puter.authToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ diff --git a/src/gui/src/UI/UIWindowChangePassword.js b/src/gui/src/UI/UIWindowChangePassword.js index ae0722d78..db210ea09 100644 --- a/src/gui/src/UI/UIWindowChangePassword.js +++ b/src/gui/src/UI/UIWindowChangePassword.js @@ -114,13 +114,12 @@ async function UIWindowChangePassword (options) { $(el_window).find('.form-error-msg').hide(); + // Do not send Authorization: user-protected endpoints use session cookie (hasHttpPowers) $.ajax({ url: `${window.api_origin }/user-protected/change-password`, type: 'POST', async: true, - headers: { - 'Authorization': `Bearer ${window.auth_token}`, - }, + xhrFields: { withCredentials: true }, contentType: 'application/json', data: JSON.stringify({ password: current_password, diff --git a/src/gui/src/UI/UIWindowChangeUsername.js b/src/gui/src/UI/UIWindowChangeUsername.js index 8e27757e7..9bbb72fda 100644 --- a/src/gui/src/UI/UIWindowChangeUsername.js +++ b/src/gui/src/UI/UIWindowChangeUsername.js @@ -182,12 +182,12 @@ async function UIWindowChangeUsername (options) { const new_username = $(el_window).find('.new-username').val(); const body = { new_username }; if ( password !== undefined && password !== '' ) body.password = password; + // Do not send Authorization: user-protected endpoints use session cookie (hasHttpPowers) return fetch(apiUrl, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', - ...(window.auth_token ? { 'Authorization': `Bearer ${window.auth_token}` } : {}), }, body: JSON.stringify(body), }); From 3a9a34560033676da210408a834ccb2819ae528b Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:41:27 -0500 Subject: [PATCH 06/39] tweak: make monthly username changes configurable The monthly number of username changes was hardcoded as `2`. Being able to configure this value makes it easier to test the username change flow. Hosters of OSS Puter may also find this configuration beneficial. --- src/backend/src/routers/change_username.js | 4 ++-- src/backend/src/routers/user-protected/change-username.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/backend/src/routers/change_username.js b/src/backend/src/routers/change_username.js index e11f52779..5955d0950 100644 --- a/src/backend/src/routers/change_username.js +++ b/src/backend/src/routers/change_username.js @@ -78,9 +78,9 @@ module.exports = eggspress('/change_username', { mysql: '`created_at` > DATE_SUB(NOW(), INTERVAL 1 MONTH)', sqlite: "`created_at` > datetime('now', '-1 month')", })}`, - [ req.user.id, 'change_username' ]); + [req.user.id, 'change_username']); - if ( rows[0].count >= 2 ) { + if ( rows[0].count >= (config.max_username_changes ?? 2) ) { throw APIError.create('too_many_username_changes'); } diff --git a/src/backend/src/routers/user-protected/change-username.js b/src/backend/src/routers/user-protected/change-username.js index 6b22eb83c..822b39ba4 100644 --- a/src/backend/src/routers/user-protected/change-username.js +++ b/src/backend/src/routers/user-protected/change-username.js @@ -57,16 +57,16 @@ module.exports = { mysql: '`created_at` > DATE_SUB(NOW(), INTERVAL 1 MONTH)', sqlite: "`created_at` > datetime('now', '-1 month')", })}`, - [ user.id, 'change_username' ]); + [user.id, 'change_username']); - if ( rows[0].count >= 2 ) { + if ( rows[0].count >= (config.max_username_changes ?? 2) ) { throw APIError.create('too_many_username_changes'); } await db.write('INSERT INTO `user_update_audit` ' + '(`user_id`, `user_id_keep`, `old_username`, `new_username`, `reason`) ' + 'VALUES (?, ?, ?, ?, ?)', - [ user.id, user.id, user.username, new_username, 'change_username' ]); + [user.id, user.id, user.username, new_username, 'change_username']); await change_username(user.id, new_username); From 142d745f0ab2ee58178f25a5e844c68d0beadc5f Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:03:09 -0500 Subject: [PATCH 07/39] fix: "Popup Closed" message, + excess logs --- src/gui/src/UI/UIWindowChangeUsername.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/gui/src/UI/UIWindowChangeUsername.js b/src/gui/src/UI/UIWindowChangeUsername.js index 9bbb72fda..4f77ac0a8 100644 --- a/src/gui/src/UI/UIWindowChangeUsername.js +++ b/src/gui/src/UI/UIWindowChangeUsername.js @@ -146,15 +146,15 @@ async function UIWindowChangeUsername (options) { onDone && onDone(new Error('No revalidate URL')); return null; } + let doneCalled = false; const hint = $(el_window).find('.change-username-oidc-hint'); hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); const onMessage = (ev) => { if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; - - console.log('is this condition working?', ev.origin !== (window.gui_origin || window.location.origin)); - console.log('If TRUE something does not match:', ev.origin, window.gui_origin, window.location.origin); + if ( doneCalled ) return; + doneCalled = true; window.removeEventListener('message', onMessage); revalidated = true; hint.hide(); @@ -168,7 +168,10 @@ async function UIWindowChangeUsername (options) { clearInterval(checkClosed); window.removeEventListener('message', onMessage); hint.hide(); - onDone && onDone(new Error('Popup closed')); + if ( ! doneCalled ) { + doneCalled = true; + onDone && onDone(new Error('Popup closed')); + } } }, 300); return popup; From 0b8eafa128a5177ddff00d61689837fc24cb1cbb Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:12:51 -0500 Subject: [PATCH 08/39] dev(oidc): re-auth remaining protected endpoints The OIDC re-authentication flow, which replaces password confirmation for accounts that were created with OIDC and do not have a password, was previously added to "change username" for manual testing of the backend-side implementation. Add the re-authentication flow to the remaining user-protected endpoints, which are: - change password - change email - disable two-factor authentication When using "change password" on a new account created via OIDC, the account changes state to a passworded account which causes these flows to use password confirmation as before instead of re-authentication. --- src/gui/src/UI/Dashboard/TabSecurity.js | 94 +++++++--- src/gui/src/UI/Settings/UITabSecurity.js | 100 ++++++++--- .../src/UI/Settings/UIWindowChangeEmail.js | 169 ++++++++++++++---- src/gui/src/UI/UIWindowChangePassword.js | 169 ++++++++++++++---- 4 files changed, 417 insertions(+), 115 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabSecurity.js b/src/gui/src/UI/Dashboard/TabSecurity.js index b9990ba96..055853755 100644 --- a/src/gui/src/UI/Dashboard/TabSecurity.js +++ b/src/gui/src/UI/Dashboard/TabSecurity.js @@ -150,32 +150,79 @@ const TabSecurity = { $el_window.find('.dashboard-section-security .disable-2fa').on('click', async function (e) { let win; const password_confirm_promise = new TeePromise(); - const try_password = async () => { - const value = $win.find('.password-entry').val(); - // Do not send Authorization: user-protected endpoints use session cookie (hasHttpPowers) - const resp = await fetch(`${window.api_origin}/user-protected/disable-2fa`, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - password: value, - }), - }); - if ( resp.status !== 200 ) { - /* eslint no-empty: ["error", { "allowEmptyCatch": true }] */ - let message; try { - message = (await resp.json()).message; - } catch (e) { + + function openRevalidatePopup (revalidateUrl, onDone) { + const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + if ( ! url ) { + onDone && onDone(new Error('No revalidate URL')); + return null; + } + let doneCalled = false; + const hint = $win.find('.disable-2fa-oidc-hint'); + hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); + const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); + const onMessage = (ev) => { + if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; + if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; + if ( doneCalled ) return; + doneCalled = true; + window.removeEventListener('message', onMessage); + hint.hide(); + onDone && onDone(); + }; + window.addEventListener('message', onMessage); + const checkClosed = setInterval(() => { + if ( popup && popup.closed ) { + clearInterval(checkClosed); + window.removeEventListener('message', onMessage); + hint.hide(); + if ( ! doneCalled ) { + doneCalled = true; + onDone && onDone(new Error('Popup closed')); + } } - message = message || i18n('error_unknown_cause'); - $win.find('.password-entry').addClass('error'); - $win.find('.error-message').text(message).show(); + }, 300); + return popup; + } + + const doRequest = () => fetch(`${window.api_origin}/user-protected/disable-2fa`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: $win.find('.password-entry').val() }), + }); + + const try_password = async () => { + const resp = await doRequest(); + if ( resp.status === 200 ) { + password_confirm_promise.resolve(true); + $(win).close(); return; } - password_confirm_promise.resolve(true); - $(win).close(); + const data = await resp.json().catch(() => ({})); + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + openRevalidatePopup(data.revalidate_url, async (err) => { + if ( err ) { + $win.find('.error-message').text(err.message || 'Re-validation required.').show(); + return; + } + const r2 = await doRequest(); + if ( r2.status === 200 ) { + password_confirm_promise.resolve(true); + $(win).close(); + } else { + let message; try { + message = (await r2.json()).message; + } catch (e) { + } + $win.find('.error-message').text(message || i18n('error_unknown_cause')).show(); + } + }); + return; + } + const message = data.message || i18n('error_unknown_cause'); + $win.find('.password-entry').addClass('error'); + $win.find('.error-message').text(message).show(); }; let h = ''; @@ -186,6 +233,7 @@ const TabSecurity = { h += ''; h += '
'; h += ''; + h += ''; h += ''; h += '
'; h += '
'; diff --git a/src/gui/src/UI/Settings/UITabSecurity.js b/src/gui/src/UI/Settings/UITabSecurity.js index af2ca184d..d4518d0fe 100644 --- a/src/gui/src/UI/Settings/UITabSecurity.js +++ b/src/gui/src/UI/Settings/UITabSecurity.js @@ -82,33 +82,83 @@ export default { }); $el_window.find('.disable-2fa').on('click', async function (e) { - let win, password_entry; + let win; const password_confirm_promise = new TeePromise(); - const try_password = async () => { - const value = password_entry.get('value'); - // No Authorization header: user-protected endpoints use session cookie (hasHttpPowers) - const resp = await fetch(`${window.api_origin}/user-protected/disable-2fa`, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - password: value, - }), - }); - if ( resp.status !== 200 ) { - /* eslint no-empty: ["error", { "allowEmptyCatch": true }] */ - let message; try { - message = (await resp.json()).message; - } catch (e) { + + function openRevalidatePopup ($win, revalidateUrl, onDone) { + const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + if ( ! url ) { + onDone && onDone(new Error('No revalidate URL')); + return null; + } + let doneCalled = false; + const hint = $win.find('.disable-2fa-oidc-hint'); + hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); + const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); + const onMessage = (ev) => { + if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; + if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; + if ( doneCalled ) return; + doneCalled = true; + window.removeEventListener('message', onMessage); + hint.hide(); + onDone && onDone(); + }; + window.addEventListener('message', onMessage); + const checkClosed = setInterval(() => { + if ( popup && popup.closed ) { + clearInterval(checkClosed); + window.removeEventListener('message', onMessage); + hint.hide(); + if ( ! doneCalled ) { + doneCalled = true; + onDone && onDone(new Error('Popup closed')); + } } - message = message || i18n('error_unknown_cause'); - password_entry.set('error', message); + }, 300); + return popup; + } + + const doRequest = () => fetch(`${window.api_origin}/user-protected/disable-2fa`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + password: win ? $(win).find('.password-entry').val() : '', + }), + }); + + const try_password = async () => { + const resp = await doRequest(); + if ( resp.status === 200 ) { + password_confirm_promise.resolve(true); + $(win).close(); return; } - password_confirm_promise.resolve(true); - $(win).close(); + const data = await resp.json().catch(() => ({})); + const $win = $(win); + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + openRevalidatePopup($win, data.revalidate_url, async (err) => { + if ( err ) { + $win.find('.error-message').text(err.message || 'Re-validation required.').show(); + return; + } + const r2 = await doRequest(); + if ( r2.status === 200 ) { + password_confirm_promise.resolve(true); + $(win).close(); + } else { + let message; try { + message = (await r2.json()).message; + } catch (e) { + } + $win.find('.error-message').text(message || i18n('error_unknown_cause')).show(); + } + }); + return; + } + $win.find('.password-entry').addClass('error'); + $win.find('.error-message').text(data.message || i18n('error_unknown_cause')).show(); }; let h = ''; @@ -117,8 +167,10 @@ export default { h += `

${i18n('disable_2fa_confirm')}

`; h += `

${i18n('disable_2fa_instructions')}

`; h += '
'; - h += '
'; + h += '
'; h += ''; + h += ''; + h += ''; h += ``; h += ``; h += '
'; diff --git a/src/gui/src/UI/Settings/UIWindowChangeEmail.js b/src/gui/src/UI/Settings/UIWindowChangeEmail.js index 66f58b231..3ba7398bc 100644 --- a/src/gui/src/UI/Settings/UIWindowChangeEmail.js +++ b/src/gui/src/UI/Settings/UIWindowChangeEmail.js @@ -41,11 +41,18 @@ async function UIWindowChangeEmail (options) { h += ``; h += ``; h += '
'; - // password confirmation - h += '
'; + // password / OIDC revalidate + h += '
'; + h += '
'; h += ``; h += `${place_password_entry.html}`; h += '
'; + h += ''; + h += ''; + h += '
'; // Change Email h += ``; @@ -74,6 +81,15 @@ async function UIWindowChangeEmail (options) { show_in_taskbar: false, onAppend: function (this_window) { $(this_window).find('.new-email').get(0)?.focus({ preventScroll: true }); + const oidc_only = !!(window.user && window.user.oidc_only); + const authRow = $(this_window).find('.change-email-auth-row'); + if ( oidc_only ) { + authRow.find('.change-email-password-wrap').hide(); + const oidcWrap = authRow.find('.change-email-oidc-wrap').show(); + oidcWrap.find('.change-email-revalidate-btn').text(i18n('revalidate_with_google') || 'Re-validate with Google'); + } else { + authRow.find('.change-email-oidc-wrap').hide(); + } }, window_class: 'window-publishWebsite', body_css: { @@ -87,12 +103,16 @@ async function UIWindowChangeEmail (options) { password_entry.attach(place_password_entry); - $(el_window).find('.change-email-btn').on('click', function (e) { - // hide previous error/success msg - $(el_window).find('.form-success-msg, .form-success-msg').hide(); + const origin = window.gui_origin || window.api_origin || ''; + const apiUrl = `${origin}/user-protected/change-email`; + let revalidated = false; + + $(el_window).find('.change-email-btn').on('click', async function (e) { + $(el_window).find('.form-success-msg, .form-error-msg').hide(); const new_email = $(el_window).find('.new-email').val(); - const password = $(el_window).find('.password').val(); + const password = password_entry.get('value'); + const oidc_only = !!(window.user && window.user.oidc_only); if ( ! new_email ) { $(el_window).find('.form-error-msg').html(i18n('all_fields_required')); @@ -101,45 +121,118 @@ async function UIWindowChangeEmail (options) { } $(el_window).find('.form-error-msg').hide(); - - // disable button $(el_window).find('.change-email-btn').addClass('disabled'); - // disable input $(el_window).find('.new-email').attr('disabled', true); - $.ajax({ - url: `${window.gui_origin}/user-protected/change-email`, - type: 'POST', - async: true, - // headers: { - // 'Authorization': `Bearer ${window.auth_token}`, - // }, - contentType: 'application/json', - data: JSON.stringify({ - new_email: new_email, - password: password_entry.get('value'), + const doSubmit = () => fetch(apiUrl, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + new_email, + password: password !== undefined && password !== '' ? password : undefined, }), - success: function (data) { - $(el_window).find('.form-success-msg').html(i18n('email_change_confirmation_sent')); - $(el_window).find('.form-success-msg').fadeIn(); - $(el_window).find('input').val(''); - // update email - window.user.email = new_email; - // enable button - $(el_window).find('.change-email-btn').removeClass('disabled'); - // enable input - $(el_window).find('.new-email').attr('disabled', false); - }, - error: function (err) { - $(el_window).find('.form-error-msg').html(html_encode(err.responseJSON?.message)); + }); + + if ( oidc_only && !revalidated && !password ) { + openRevalidatePopup(null, async (err) => { + if ( err ) { + onError(err.message || 'Re-validation required.'); + return; + } + const res = await doSubmit(); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + if ( res.ok ) onSuccess(); + else onError(data.message || 'Request failed'); + }); + return; + } + + let res = await doSubmit(); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + + if ( res.ok ) { + onSuccess(); + return; + } + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + openRevalidatePopup(data.revalidate_url, async (err) => { + if ( err ) { + onError(err.message || 'Re-validation required.'); + return; + } + const r2 = await doSubmit(); + const d2 = r2.ok ? await r2.json().catch(() => ({})) : await r2.json().catch(() => ({})); + if ( r2.ok ) onSuccess(); + else onError(d2.message || 'Request failed'); + }); + return; + } + onError(data.message || 'Request failed'); + }); + + function openRevalidatePopup (revalidateUrl, onDone) { + const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + if ( ! url ) { + onDone && onDone(new Error('No revalidate URL')); + return null; + } + let doneCalled = false; + const hint = $(el_window).find('.change-email-oidc-hint'); + hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); + const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); + const onMessage = (ev) => { + if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; + if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; + if ( doneCalled ) return; + doneCalled = true; + window.removeEventListener('message', onMessage); + revalidated = true; + hint.hide(); + $(el_window).find('.change-email-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); + $(el_window).find('.change-email-revalidate-btn').hide(); + onDone && onDone(); + }; + window.addEventListener('message', onMessage); + const checkClosed = setInterval(() => { + if ( popup && popup.closed ) { + clearInterval(checkClosed); + window.removeEventListener('message', onMessage); + hint.hide(); + if ( ! doneCalled ) { + doneCalled = true; + onDone && onDone(new Error('Popup closed')); + } + } + }, 300); + return popup; + } + + $(el_window).find('.change-email-revalidate-btn').on('click', function () { + openRevalidatePopup(null, (err) => { + if ( err ) { + $(el_window).find('.form-error-msg').html(html_encode(err.message || 'Re-validation required.')); $(el_window).find('.form-error-msg').fadeIn(); - // enable button - $(el_window).find('.change-email-btn').removeClass('disabled'); - // enable input - $(el_window).find('.new-email').attr('disabled', false); - }, + } }); }); + + function onError (message) { + $(el_window).find('.form-error-msg').html(html_encode(message)); + $(el_window).find('.form-error-msg').fadeIn(); + $(el_window).find('.change-email-btn').removeClass('disabled'); + $(el_window).find('.new-email').attr('disabled', false); + } + + function onSuccess () { + const new_email = $(el_window).find('.new-email').val(); + $(el_window).find('.form-success-msg').html(i18n('email_change_confirmation_sent')); + $(el_window).find('.form-success-msg').fadeIn(); + $(el_window).find('input').val(''); + window.user.email = new_email; + $(el_window).find('.change-email-btn').removeClass('disabled'); + $(el_window).find('.new-email').attr('disabled', false); + } } export default UIWindowChangeEmail; \ No newline at end of file diff --git a/src/gui/src/UI/UIWindowChangePassword.js b/src/gui/src/UI/UIWindowChangePassword.js index db210ea09..27d99e090 100644 --- a/src/gui/src/UI/UIWindowChangePassword.js +++ b/src/gui/src/UI/UIWindowChangePassword.js @@ -17,8 +17,8 @@ * along with this program. If not, see . */ -import UIWindow from './UIWindow.js'; import check_password_strength from '../helpers/check_password_strength.js'; +import UIWindow from './UIWindow.js'; async function UIWindowChangePassword (options) { options = options ?? {}; @@ -30,11 +30,17 @@ async function UIWindowChangePassword (options) { h += '
'; // success msg h += '
'; - // current password - h += '
'; + // current password / OIDC revalidate + h += '
'; + h += '
'; h += ``; h += ``; h += '
'; + h += ''; + h += '
'; // new password h += '
'; h += ``; @@ -45,6 +51,7 @@ async function UIWindowChangePassword (options) { h += ``; h += ``; h += '
'; + h += ''; // Change Password h += ``; @@ -72,7 +79,16 @@ async function UIWindowChangePassword (options) { dominant: true, show_in_taskbar: false, onAppend: function (this_window) { - $(this_window).find('.current-password').get(0).focus({ preventScroll: true }); + $(this_window).find('.current-password').get(0)?.focus({ preventScroll: true }); + const oidc_only = !!(window.user && window.user.oidc_only); + const authRow = $(this_window).find('.change-password-auth-row'); + if ( oidc_only ) { + authRow.find('.change-password-current-wrap').hide(); + const oidcWrap = authRow.find('.change-password-oidc-wrap').show(); + oidcWrap.find('.change-password-revalidate-btn').text(i18n('revalidate_with_google') || 'Re-validate with Google'); + } else { + authRow.find('.change-password-oidc-wrap').hide(); + } }, window_class: 'window-publishWebsite', body_css: { @@ -84,27 +100,47 @@ async function UIWindowChangePassword (options) { ...options.window_options, }); - $(el_window).find('.change-password-btn').on('click', function (e) { + const origin = window.gui_origin || window.api_origin || ''; + const apiUrl = `${origin}/user-protected/change-password`; + + $(el_window).find('.change-password-btn').on('click', async function (e) { const current_password = $(el_window).find('.current-password').val(); const new_password = $(el_window).find('.new-password').val(); const confirm_new_password = $(el_window).find('.confirm-new-password').val(); + const oidc_only = !!(window.user && window.user.oidc_only); - // hide success message - $(el_window).find('.form-success-msg').hide(); + $(el_window).find('.form-success-msg, .form-error-msg').hide(); - // check if all fields are filled - if ( !current_password || !new_password || !confirm_new_password ) { + if ( !new_password || !confirm_new_password ) { $(el_window).find('.form-error-msg').html('All fields are required.'); $(el_window).find('.form-error-msg').fadeIn(); return; } - // check if new password and confirm new password are the same - else if ( new_password !== confirm_new_password ) { + // For password users, current password is required; for OIDC, we need revalidated or will open popup + if ( !oidc_only && !current_password ) { + $(el_window).find('.form-error-msg').html('All fields are required.'); + $(el_window).find('.form-error-msg').fadeIn(); + return; + } + if ( oidc_only && !revalidated && !current_password ) { + $(el_window).find('.change-password-btn').addClass('disabled'); + openRevalidatePopup(null, async (err) => { + if ( err ) { + onError(err.message || 'Re-validation required.'); + return; + } + const res = await doSubmit(''); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + if ( res.ok ) onSuccess(); + else onError(data.message || 'Request failed'); + }); + return; + } + if ( new_password !== confirm_new_password ) { $(el_window).find('.form-error-msg').html(i18n('passwords_do_not_match')); $(el_window).find('.form-error-msg').fadeIn(); return; } - // check password strength const pass_strength = check_password_strength(new_password); if ( ! pass_strength.overallPass ) { $(el_window).find('.form-error-msg').html(i18n('password_strength_error')); @@ -113,29 +149,102 @@ async function UIWindowChangePassword (options) { } $(el_window).find('.form-error-msg').hide(); + $(el_window).find('.change-password-btn').addClass('disabled'); + $(el_window).find('.current-password, .new-password, .confirm-new-password').attr('disabled', true); - // Do not send Authorization: user-protected endpoints use session cookie (hasHttpPowers) - $.ajax({ - url: `${window.api_origin }/user-protected/change-password`, - type: 'POST', - async: true, - xhrFields: { withCredentials: true }, - contentType: 'application/json', - data: JSON.stringify({ - password: current_password, + const doSubmit = (currentPass) => fetch(apiUrl, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + password: currentPass !== undefined ? currentPass : current_password, new_pass: new_password, }), - success: function (data) { - $(el_window).find('.form-success-msg').html(i18n('password_changed')); - $(el_window).find('.form-success-msg').fadeIn(); - $(el_window).find('input').val(''); - }, - error: function (err) { - $(el_window).find('.form-error-msg').html(html_encode(err.responseText)); - $(el_window).find('.form-error-msg').fadeIn(); - }, + }); + + let res = await doSubmit(current_password); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + + if ( res.ok ) { + onSuccess(); + return; + } + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + openRevalidatePopup(data.revalidate_url, async (err) => { + if ( err ) { + onError(err.message || 'Re-validation required.'); + return; + } + const r2 = await doSubmit(''); + const d2 = r2.ok ? await r2.json().catch(() => ({})) : await r2.json().catch(() => ({})); + if ( r2.ok ) onSuccess(); + else onError(d2.message || 'Request failed'); + }); + return; + } + onError(data.message || res.statusText || 'Request failed'); + }); + let revalidated = false; + + function openRevalidatePopup (revalidateUrl, onDone) { + const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + if ( ! url ) { + onDone && onDone(new Error('No revalidate URL')); + return null; + } + let doneCalled = false; + const hint = $(el_window).find('.change-password-oidc-hint'); + hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); + const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); + const onMessage = (ev) => { + if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; + if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; + if ( doneCalled ) return; + doneCalled = true; + window.removeEventListener('message', onMessage); + revalidated = true; + hint.hide(); + $(el_window).find('.change-password-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); + $(el_window).find('.change-password-revalidate-btn').hide(); + onDone && onDone(); + }; + window.addEventListener('message', onMessage); + const checkClosed = setInterval(() => { + if ( popup && popup.closed ) { + clearInterval(checkClosed); + window.removeEventListener('message', onMessage); + hint.hide(); + if ( ! doneCalled ) { + doneCalled = true; + onDone && onDone(new Error('Popup closed')); + } + } + }, 300); + return popup; + } + + $(el_window).find('.change-password-revalidate-btn').on('click', function () { + openRevalidatePopup(null, (err) => { + if ( err ) { + onError(err.message || 'Re-validation required.'); + } }); }); + + function onError (message) { + $(el_window).find('.form-error-msg').html(html_encode(message)); + $(el_window).find('.form-error-msg').fadeIn(); + $(el_window).find('.change-password-btn').removeClass('disabled'); + $(el_window).find('.current-password, .new-password, .confirm-new-password').attr('disabled', false); + } + + function onSuccess () { + $(el_window).find('.form-success-msg').html(i18n('password_changed')); + $(el_window).find('.form-success-msg').fadeIn(); + $(el_window).find('input').val(''); + $(el_window).find('.change-password-btn').removeClass('disabled'); + $(el_window).find('.current-password, .new-password, .confirm-new-password').attr('disabled', false); + } } export default UIWindowChangePassword; \ No newline at end of file From df1f5c44cc2910a4728ad1cbde6b0d2255d437e5 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:27:01 -0500 Subject: [PATCH 09/39] refactor(oidc): extract common (email + username) There is common functionality between all of the GUI code for actions on protected endpoints. Update UIWindowChangeEmail and UIWindowChangeUsername to both use a new utility function called openRevalidatePopup in util/openid.js. This file is called `openid.js` instead of `oidc.js` so that it's more easily recognized by contributors who might be more familiar with the name of the organization than the name of the standard itself. After these changes, UIWindowChangePassword and the "disable 2FA" button in UITabSecurity still need to be updated to use `util/openid.js` instead of duplicating this functionality. The justification for following DRY here instead of leaving the implementation as-is is because these flows are particularly error prone and will be difficult to maintain without this consistency. Some subtle bugs I previously wasn't aware of got fixed in the process. --- .../src/UI/Settings/UIWindowChangeEmail.js | 129 +++++++----------- src/gui/src/UI/UIWindowChangeUsername.js | 84 +++++------- src/gui/src/util/openid.js | 61 +++++++++ 3 files changed, 141 insertions(+), 133 deletions(-) create mode 100644 src/gui/src/util/openid.js diff --git a/src/gui/src/UI/Settings/UIWindowChangeEmail.js b/src/gui/src/UI/Settings/UIWindowChangeEmail.js index 3ba7398bc..abbbe6aa0 100644 --- a/src/gui/src/UI/Settings/UIWindowChangeEmail.js +++ b/src/gui/src/UI/Settings/UIWindowChangeEmail.js @@ -17,6 +17,7 @@ * along with this program. If not, see . */ +import { openRevalidatePopup } from '../../util/openid.js'; import Placeholder from '../../util/Placeholder.js'; import PasswordEntry from '../Components/PasswordEntry.js'; import UIWindow from '../UIWindow.js'; @@ -107,6 +108,25 @@ async function UIWindowChangeEmail (options) { const apiUrl = `${origin}/user-protected/change-email`; let revalidated = false; + const hint = $(el_window).find('.change-email-oidc-hint'); + const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.'; + + const myOpenRevalidatePopup = async (revalidateUrl) => { + revalidateUrl = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + $(el_window).find('.change-email-btn').addClass('disabled'); + hint.text(REVALIDATE_POPUP_TEXT).show(); + try { + await openRevalidatePopup(revalidateUrl); + } catch (e) { + onError(e.message || 'Authentication failed'); + return; + } finally { + hint.hide(); + } + $(el_window).find('.change-email-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); + $(el_window).find('.change-email-revalidate-btn').hide(); + }; + $(el_window).find('.change-email-btn').on('click', async function (e) { $(el_window).find('.form-success-msg, .form-error-msg').hide(); @@ -120,11 +140,38 @@ async function UIWindowChangeEmail (options) { return; } + if ( oidc_only && !revalidated && !password ) { + await myOpenRevalidatePopup(); + + const res = await doSubmit({ new_email }); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + if ( res.ok ) onSuccess(); + else onError(data.message || 'Request failed'); + return; + } $(el_window).find('.form-error-msg').hide(); $(el_window).find('.change-email-btn').addClass('disabled'); $(el_window).find('.new-email').attr('disabled', true); - const doSubmit = () => fetch(apiUrl, { + let res = await doSubmit({ new_email, password }); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + + if ( res.ok ) { + onSuccess(); + return; + } + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + await myOpenRevalidatePopup(data.revalidate_url); + const r = await doSubmit(); + if ( r.ok ) onSuccess(); + else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed')); + return; + } + onError(data.message || 'Request failed'); + }); + + function doSubmit ({ new_email, password }) { + return fetch(apiUrl, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, @@ -133,88 +180,10 @@ async function UIWindowChangeEmail (options) { password: password !== undefined && password !== '' ? password : undefined, }), }); - - if ( oidc_only && !revalidated && !password ) { - openRevalidatePopup(null, async (err) => { - if ( err ) { - onError(err.message || 'Re-validation required.'); - return; - } - const res = await doSubmit(); - const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); - if ( res.ok ) onSuccess(); - else onError(data.message || 'Request failed'); - }); - return; - } - - let res = await doSubmit(); - const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); - - if ( res.ok ) { - onSuccess(); - return; - } - if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { - openRevalidatePopup(data.revalidate_url, async (err) => { - if ( err ) { - onError(err.message || 'Re-validation required.'); - return; - } - const r2 = await doSubmit(); - const d2 = r2.ok ? await r2.json().catch(() => ({})) : await r2.json().catch(() => ({})); - if ( r2.ok ) onSuccess(); - else onError(d2.message || 'Request failed'); - }); - return; - } - onError(data.message || 'Request failed'); - }); - - function openRevalidatePopup (revalidateUrl, onDone) { - const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); - if ( ! url ) { - onDone && onDone(new Error('No revalidate URL')); - return null; - } - let doneCalled = false; - const hint = $(el_window).find('.change-email-oidc-hint'); - hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); - const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); - const onMessage = (ev) => { - if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; - if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; - if ( doneCalled ) return; - doneCalled = true; - window.removeEventListener('message', onMessage); - revalidated = true; - hint.hide(); - $(el_window).find('.change-email-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); - $(el_window).find('.change-email-revalidate-btn').hide(); - onDone && onDone(); - }; - window.addEventListener('message', onMessage); - const checkClosed = setInterval(() => { - if ( popup && popup.closed ) { - clearInterval(checkClosed); - window.removeEventListener('message', onMessage); - hint.hide(); - if ( ! doneCalled ) { - doneCalled = true; - onDone && onDone(new Error('Popup closed')); - } - } - }, 300); - return popup; } $(el_window).find('.change-email-revalidate-btn').on('click', function () { - openRevalidatePopup(null, (err) => { - if ( err ) { - $(el_window).find('.form-error-msg').html(html_encode(err.message || 'Re-validation required.')); - $(el_window).find('.form-error-msg').fadeIn(); - } - }); + myOpenRevalidatePopup(); }); function onError (message) { diff --git a/src/gui/src/UI/UIWindowChangeUsername.js b/src/gui/src/UI/UIWindowChangeUsername.js index 4f77ac0a8..370f5c2b3 100644 --- a/src/gui/src/UI/UIWindowChangeUsername.js +++ b/src/gui/src/UI/UIWindowChangeUsername.js @@ -18,6 +18,7 @@ */ import update_username_in_gui from '../helpers/update_username_in_gui.js'; +import { openRevalidatePopup } from '../util/openid.js'; import UIWindow from './UIWindow.js'; async function UIWindowChangeUsername (options) { @@ -93,6 +94,25 @@ async function UIWindowChangeUsername (options) { const apiUrl = `${origin}/user-protected/change-username`; let revalidated = false; + const hint = $(el_window).find('.change-username-oidc-hint'); + const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.'; + + const myOpenRevalidatePopup = async (revalidateUrl) => { + revalidateUrl = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + $(el_window).find('.change-username-btn').addClass('disabled'); + hint.text(REVALIDATE_POPUP_TEXT).show(); + try { + await openRevalidatePopup(revalidateUrl); + } catch (e) { + onError(e.message || 'Authentication failed'); + return; + } finally { + hint.hide(); + } + $(el_window).find('.change-username-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); + $(el_window).find('.change-username-revalidate-btn').hide(); + }; + $(el_window).find('.change-username-btn').on('click', async function (e) { $(el_window).find('.form-success-msg, .form-error-msg').hide(); const new_username = $(el_window).find('.new-username').val(); @@ -106,16 +126,12 @@ async function UIWindowChangeUsername (options) { } if ( oidc_only && !revalidated && !password ) { $(el_window).find('.change-username-btn').addClass('disabled'); - openRevalidatePopup(null, async (err) => { - if ( err ) { - onError(err.message || 'Re-validation required.'); - return; - } - const res = await doSubmit(); - const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); - if ( res.ok ) onSuccess(); - else onError(data.message || 'Request failed'); - }); + myOpenRevalidatePopup(); + + const res = await doSubmit(); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + if ( res.ok ) onSuccess(); + else onError(data.message || 'Request failed'); return; } $(el_window).find('.form-error-msg').hide(); @@ -130,55 +146,17 @@ async function UIWindowChangeUsername (options) { return; } if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { - openRevalidatePopup(data.revalidate_url, async () => { - const r = await doSubmit(); - if ( r.ok ) onSuccess(); - else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed')); - }); + myOpenRevalidatePopup(data.revalidate_url); + const r = await doSubmit(); + if ( r.ok ) onSuccess(); + else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed')); return; } onError(data.message || 'Request failed'); }); - function openRevalidatePopup (revalidateUrl, onDone) { - const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); - if ( ! url ) { - onDone && onDone(new Error('No revalidate URL')); - return null; - } - let doneCalled = false; - const hint = $(el_window).find('.change-username-oidc-hint'); - hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); - const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); - const onMessage = (ev) => { - if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; - if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; - if ( doneCalled ) return; - doneCalled = true; - window.removeEventListener('message', onMessage); - revalidated = true; - hint.hide(); - $(el_window).find('.change-username-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); - $(el_window).find('.change-username-revalidate-btn').hide(); - onDone && onDone(); - }; - window.addEventListener('message', onMessage); - const checkClosed = setInterval(() => { - if ( popup && popup.closed ) { - clearInterval(checkClosed); - window.removeEventListener('message', onMessage); - hint.hide(); - if ( ! doneCalled ) { - doneCalled = true; - onDone && onDone(new Error('Popup closed')); - } - } - }, 300); - return popup; - } - $(el_window).find('.change-username-revalidate-btn').on('click', function () { - openRevalidatePopup(); + myOpenRevalidatePopup(); }); function doSubmit (password) { diff --git a/src/gui/src/util/openid.js b/src/gui/src/util/openid.js new file mode 100644 index 000000000..ab73ef6ec --- /dev/null +++ b/src/gui/src/util/openid.js @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2026-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 . + */ + +import TeePromise from './TeePromise.js'; + +/** + * This file contains common functions that are used to re-authenticate an + * OIDC-authenticated user when performing actions on protected endpoints. + * + * No design patterns, no abstractions; only simple functions. + * (this is not merely a description; it is a guideline for future changes) + */ + +const POPUP_FEATURES = 'width=500,height=600'; + +export const openRevalidatePopup = async (revalidateUrl) => { + const donePromise = new TeePromise(); + + const url = revalidateUrl; + if ( ! url ) { + throw new Error('No revalidate URL'); + } + let doneCalled = false; + const popup = window.open(url, 'puter-revalidate', POPUP_FEATURES); + const onMessage = ev => { + if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; + if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; + if ( doneCalled ) return; + doneCalled = true; + window.removeEventListener('message', onMessage); + donePromise.resolve(); + }; + window.addEventListener('message', onMessage); + const checkClosed = setInterval(() => { + if ( popup && popup.closed ) { + clearInterval(checkClosed); + window.removeEventListener('message', onMessage); + if ( ! doneCalled ) { + doneCalled = true; + donePromise.reject(new Error('Popup closed')); + } + } + }, 300); + await donePromise; +}; From 8923bdac956a1cf8ba322949d8bd71d928a44696 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:26:25 -0500 Subject: [PATCH 10/39] refactor(oidc): update UIWindowChangePassword Use the openRevalidatePopup function in util/openid.js within UIWindowChangePassword instead of re-implementing that functionality. Additionally, normalize some of the code so it is more similar to UIWindowChangeUsername and UIWindowChangePassword. --- src/gui/src/UI/UIWindowChangePassword.js | 151 ++++++++++++----------- 1 file changed, 81 insertions(+), 70 deletions(-) diff --git a/src/gui/src/UI/UIWindowChangePassword.js b/src/gui/src/UI/UIWindowChangePassword.js index 27d99e090..aa0feef35 100644 --- a/src/gui/src/UI/UIWindowChangePassword.js +++ b/src/gui/src/UI/UIWindowChangePassword.js @@ -18,6 +18,7 @@ */ import check_password_strength from '../helpers/check_password_strength.js'; +import { openRevalidatePopup } from '../util/openid.js'; import UIWindow from './UIWindow.js'; async function UIWindowChangePassword (options) { @@ -102,6 +103,26 @@ async function UIWindowChangePassword (options) { const origin = window.gui_origin || window.api_origin || ''; const apiUrl = `${origin}/user-protected/change-password`; + let revalidated = false; + + const hint = $(el_window).find('.change-password-oidc-hint'); + const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.'; + + const myOpenRevalidatePopup = async (revalidateUrl) => { + revalidateUrl = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + $(el_window).find('.change-password-btn').addClass('disabled'); + hint.text(REVALIDATE_POPUP_TEXT).show(); + try { + await openRevalidatePopup(revalidateUrl); + } catch (e) { + onError(e.message || 'Authentication failed'); + return; + } finally { + hint.hide(); + } + $(el_window).find('.change-password-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); + $(el_window).find('.change-password-revalidate-btn').hide(); + }; $(el_window).find('.change-password-btn').on('click', async function (e) { const current_password = $(el_window).find('.current-password').val(); @@ -122,20 +143,6 @@ async function UIWindowChangePassword (options) { $(el_window).find('.form-error-msg').fadeIn(); return; } - if ( oidc_only && !revalidated && !current_password ) { - $(el_window).find('.change-password-btn').addClass('disabled'); - openRevalidatePopup(null, async (err) => { - if ( err ) { - onError(err.message || 'Re-validation required.'); - return; - } - const res = await doSubmit(''); - const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); - if ( res.ok ) onSuccess(); - else onError(data.message || 'Request failed'); - }); - return; - } if ( new_password !== confirm_new_password ) { $(el_window).find('.form-error-msg').html(i18n('passwords_do_not_match')); $(el_window).find('.form-error-msg').fadeIn(); @@ -148,20 +155,20 @@ async function UIWindowChangePassword (options) { return; } + if ( oidc_only && !revalidated && !current_password ) { + await myOpenRevalidatePopup(); + + const res = await doSubmit({ new_password }); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + if ( res.ok ) onSuccess(); + else onError(data.message || 'Request failed'); + return; + } + $(el_window).find('.form-error-msg').hide(); $(el_window).find('.change-password-btn').addClass('disabled'); $(el_window).find('.current-password, .new-password, .confirm-new-password').attr('disabled', true); - const doSubmit = (currentPass) => fetch(apiUrl, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - password: currentPass !== undefined ? currentPass : current_password, - new_pass: new_password, - }), - }); - let res = await doSubmit(current_password); const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); @@ -170,57 +177,61 @@ async function UIWindowChangePassword (options) { return; } if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { - openRevalidatePopup(data.revalidate_url, async (err) => { - if ( err ) { - onError(err.message || 'Re-validation required.'); - return; - } - const r2 = await doSubmit(''); - const d2 = r2.ok ? await r2.json().catch(() => ({})) : await r2.json().catch(() => ({})); - if ( r2.ok ) onSuccess(); - else onError(d2.message || 'Request failed'); - }); + await myOpenRevalidatePopup(data.revalidate_url); + const r = await doSubmit(); + if ( r.ok ) onSuccess(); + else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed')); return; } onError(data.message || res.statusText || 'Request failed'); }); - let revalidated = false; - function openRevalidatePopup (revalidateUrl, onDone) { - const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); - if ( ! url ) { - onDone && onDone(new Error('No revalidate URL')); - return null; - } - let doneCalled = false; - const hint = $(el_window).find('.change-password-oidc-hint'); - hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); - const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); - const onMessage = (ev) => { - if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; - if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; - if ( doneCalled ) return; - doneCalled = true; - window.removeEventListener('message', onMessage); - revalidated = true; - hint.hide(); - $(el_window).find('.change-password-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); - $(el_window).find('.change-password-revalidate-btn').hide(); - onDone && onDone(); - }; - window.addEventListener('message', onMessage); - const checkClosed = setInterval(() => { - if ( popup && popup.closed ) { - clearInterval(checkClosed); - window.removeEventListener('message', onMessage); - hint.hide(); - if ( ! doneCalled ) { - doneCalled = true; - onDone && onDone(new Error('Popup closed')); - } - } - }, 300); - return popup; + // function openRevalidatePopup (revalidateUrl, onDone) { + // const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + // if ( ! url ) { + // onDone && onDone(new Error('No revalidate URL')); + // return null; + // } + // let doneCalled = false; + // const hint = $(el_window).find('.change-password-oidc-hint'); + // hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); + // const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); + // const onMessage = (ev) => { + // if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; + // if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; + // if ( doneCalled ) return; + // doneCalled = true; + // window.removeEventListener('message', onMessage); + // revalidated = true; + // hint.hide(); + // $(el_window).find('.change-password-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); + // $(el_window).find('.change-password-revalidate-btn').hide(); + // onDone && onDone(); + // }; + // window.addEventListener('message', onMessage); + // const checkClosed = setInterval(() => { + // if ( popup && popup.closed ) { + // clearInterval(checkClosed); + // window.removeEventListener('message', onMessage); + // hint.hide(); + // if ( ! doneCalled ) { + // doneCalled = true; + // onDone && onDone(new Error('Popup closed')); + // } + // } + // }, 300); + // return popup; + // } + function doSubmit ({ new_password, current_password }) { + return fetch(apiUrl, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + password: current_password, + new_pass: new_password, + }), + }); } $(el_window).find('.change-password-revalidate-btn').on('click', function () { From e2068e7b9c2f381653bb2794a3c52f7279ee34aa Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:46:58 -0500 Subject: [PATCH 11/39] fix(oidc): fix QR code login issues caused by OIDC In implementing OIDC it became necessary to introduce the separation of "GUI Tokens" and "Session Tokens". This breaks QR login because Puter does not set the HTTP-only session cookie when logging in with this flow. Add a middelware to WebServerService to detect QR Code logins and set the appropriate HTTP-only session cookie. --- .../src/modules/web/WebServerService.js | 28 +++++++++++++++++++ .../routers/user-protected/change-username.js | 2 +- src/backend/src/services/auth/AuthService.js | 19 +++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/backend/src/modules/web/WebServerService.js b/src/backend/src/modules/web/WebServerService.js index 1814b4412..7db1ad6a4 100644 --- a/src/backend/src/modules/web/WebServerService.js +++ b/src/backend/src/modules/web/WebServerService.js @@ -337,6 +337,34 @@ class WebServerService extends BaseService { next(); }); + // When the user visits the main origin (not api/dav subdomain) with ?auth_token= + // (e.g. QR login), set the HTTP-only session cookie so user-protected endpoints work. + app.use(async (req, res, next) => { + const has_subdomain = req.hostname.slice(0, -1 * (config.domain.length + 1)) !== ''; + if ( has_subdomain ) return next(); + + const token = req.query?.auth_token; + if ( !token || typeof token !== 'string' ) return next(); + + try { + const svc_auth = req.services.get('auth'); + const cleanToken = token.replace('Bearer ', '').trim(); + const actor = await svc_auth.authenticate_from_token(cleanToken); + const session_token = svc_auth.create_session_token_for_session( + actor.type.user, + actor.type.session, + ); + res.cookie(config.cookie_name, session_token, { + sameSite: 'none', + secure: true, + httpOnly: true, + }); + } catch ( _e ) { + // Invalid or expired token; do not set cookie + } + next(); + }); + // Measure data transfer amounts app.use(measure()); diff --git a/src/backend/src/routers/user-protected/change-username.js b/src/backend/src/routers/user-protected/change-username.js index 822b39ba4..ece0c5fc7 100644 --- a/src/backend/src/routers/user-protected/change-username.js +++ b/src/backend/src/routers/user-protected/change-username.js @@ -25,7 +25,7 @@ const { Context } = require('../../util/context'); module.exports = { route: '/change-username', methods: ['POST'], - handler: async (req, res, next) => { + handler: async (req, res, _next) => { const user = req.user; const new_username = req.body.new_username; diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index 91b6ad0a4..1881c3ed1 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -355,6 +355,25 @@ class AuthService extends BaseService { }, this.global_config.jwt_secret); } + /** + * Creates a session token (hasHttpPowers) for an existing session. + * Used when the client authenticated with a GUI token (e.g. QR login via + * ?auth_token=) so we can set the HTTP-only cookie and allow user-protected + * endpoints (change password, email, username, etc.) to work. + * + * @param {*} user - User object (must have .uuid). + * @param {string} session_uuid - Existing session UUID. + * @returns {string} JWT session token. + */ + create_session_token_for_session (user, session_uuid) { + return this.modules.jwt.sign({ + type: 'session', + version: '0.0.0', + uuid: session_uuid, + user_uid: user.uuid, + }, this.global_config.jwt_secret); + } + /** * This method checks if the provided session token is valid and returns the associated user and token. * If the token is not a valid session token or it does not exist in the database, it returns an empty object. From 21e959bbaa1d9050d9b0caa2570943526f2a0f7b Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:32:09 -0500 Subject: [PATCH 12/39] dev(oidc): remove button to manually invoke re-auth This button was useful during manual testing, but the re-authentication flow for protected endpoints with OIDC users reliably invokes the popup, so this is no longer necessary. Removing this button reduces clutter on these screens and might make the flow easier for users to understand. --- src/gui/src/UI/Settings/UITabSecurity.js | 4 ++++ src/gui/src/UI/Settings/UIWindowChangeEmail.js | 12 +++++------- src/gui/src/UI/UIWindowChangePassword.js | 16 +++++----------- src/gui/src/UI/UIWindowChangeUsername.js | 12 +++++------- src/gui/src/i18n/translations/en.js | 1 + 5 files changed, 20 insertions(+), 25 deletions(-) diff --git a/src/gui/src/UI/Settings/UITabSecurity.js b/src/gui/src/UI/Settings/UITabSecurity.js index d4518d0fe..4e5711bbd 100644 --- a/src/gui/src/UI/Settings/UITabSecurity.js +++ b/src/gui/src/UI/Settings/UITabSecurity.js @@ -161,11 +161,15 @@ export default { $win.find('.error-message').text(data.message || i18n('error_unknown_cause')).show(); }; + const oidc_only = !!(window.user && window.user.oidc_only); let h = ''; h += '
'; h += '
'; h += `

${i18n('disable_2fa_confirm')}

`; h += `

${i18n('disable_2fa_instructions')}

`; + if ( oidc_only ) { + h += `

${i18n('revalidate_flow_notice')}

`; + } h += '
'; h += '
'; h += ''; diff --git a/src/gui/src/UI/Settings/UIWindowChangeEmail.js b/src/gui/src/UI/Settings/UIWindowChangeEmail.js index abbbe6aa0..a09e66cb1 100644 --- a/src/gui/src/UI/Settings/UIWindowChangeEmail.js +++ b/src/gui/src/UI/Settings/UIWindowChangeEmail.js @@ -49,7 +49,7 @@ async function UIWindowChangeEmail (options) { h += `${place_password_entry.html}`; h += '
'; h += ''; h += ''; @@ -87,7 +87,10 @@ async function UIWindowChangeEmail (options) { if ( oidc_only ) { authRow.find('.change-email-password-wrap').hide(); const oidcWrap = authRow.find('.change-email-oidc-wrap').show(); - oidcWrap.find('.change-email-revalidate-btn').text(i18n('revalidate_with_google') || 'Re-validate with Google'); + oidcWrap.find('.change-email-oidc-flow-notice').text( + i18n('revalidate_flow_notice') || + 'You will be asked to sign in with your linked account when you continue.', + ); } else { authRow.find('.change-email-oidc-wrap').hide(); } @@ -124,7 +127,6 @@ async function UIWindowChangeEmail (options) { hint.hide(); } $(el_window).find('.change-email-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); - $(el_window).find('.change-email-revalidate-btn').hide(); }; $(el_window).find('.change-email-btn').on('click', async function (e) { @@ -182,10 +184,6 @@ async function UIWindowChangeEmail (options) { }); } - $(el_window).find('.change-email-revalidate-btn').on('click', function () { - myOpenRevalidatePopup(); - }); - function onError (message) { $(el_window).find('.form-error-msg').html(html_encode(message)); $(el_window).find('.form-error-msg').fadeIn(); diff --git a/src/gui/src/UI/UIWindowChangePassword.js b/src/gui/src/UI/UIWindowChangePassword.js index aa0feef35..528c01e4f 100644 --- a/src/gui/src/UI/UIWindowChangePassword.js +++ b/src/gui/src/UI/UIWindowChangePassword.js @@ -38,7 +38,7 @@ async function UIWindowChangePassword (options) { h += ``; h += '
'; h += ''; h += '
'; @@ -86,7 +86,10 @@ async function UIWindowChangePassword (options) { if ( oidc_only ) { authRow.find('.change-password-current-wrap').hide(); const oidcWrap = authRow.find('.change-password-oidc-wrap').show(); - oidcWrap.find('.change-password-revalidate-btn').text(i18n('revalidate_with_google') || 'Re-validate with Google'); + oidcWrap.find('.change-password-oidc-flow-notice').text( + i18n('revalidate_flow_notice') || + 'You will be asked to sign in with your linked account when you continue.', + ); } else { authRow.find('.change-password-oidc-wrap').hide(); } @@ -121,7 +124,6 @@ async function UIWindowChangePassword (options) { hint.hide(); } $(el_window).find('.change-password-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); - $(el_window).find('.change-password-revalidate-btn').hide(); }; $(el_window).find('.change-password-btn').on('click', async function (e) { @@ -234,14 +236,6 @@ async function UIWindowChangePassword (options) { }); } - $(el_window).find('.change-password-revalidate-btn').on('click', function () { - openRevalidatePopup(null, (err) => { - if ( err ) { - onError(err.message || 'Re-validation required.'); - } - }); - }); - function onError (message) { $(el_window).find('.form-error-msg').html(html_encode(message)); $(el_window).find('.form-error-msg').fadeIn(); diff --git a/src/gui/src/UI/UIWindowChangeUsername.js b/src/gui/src/UI/UIWindowChangeUsername.js index 370f5c2b3..c54babfd9 100644 --- a/src/gui/src/UI/UIWindowChangeUsername.js +++ b/src/gui/src/UI/UIWindowChangeUsername.js @@ -39,7 +39,7 @@ async function UIWindowChangeUsername (options) { h += ``; h += '
'; h += ''; h += ''; @@ -75,7 +75,10 @@ async function UIWindowChangeUsername (options) { if ( oidc_only ) { authRow.find('.change-username-password-wrap').hide(); const oidcWrap = authRow.find('.change-username-oidc-wrap').show(); - oidcWrap.find('.change-username-revalidate-btn').text(i18n('revalidate_with_google') || 'Re-validate with Google'); + oidcWrap.find('.change-username-oidc-flow-notice').text( + i18n('revalidate_flow_notice') || + 'You will be asked to sign in with your linked account when you continue.', + ); } else { authRow.find('.change-username-oidc-wrap').hide(); } @@ -110,7 +113,6 @@ async function UIWindowChangeUsername (options) { hint.hide(); } $(el_window).find('.change-username-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); - $(el_window).find('.change-username-revalidate-btn').hide(); }; $(el_window).find('.change-username-btn').on('click', async function (e) { @@ -155,10 +157,6 @@ async function UIWindowChangeUsername (options) { onError(data.message || 'Request failed'); }); - $(el_window).find('.change-username-revalidate-btn').on('click', function () { - myOpenRevalidatePopup(); - }); - function doSubmit (password) { const new_username = $(el_window).find('.new-username').val(); const body = { new_username }; diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index babdb5763..6e2c1b8d9 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -52,6 +52,7 @@ const en = { revalidate_with_google: 'Re-validate with Google', revalidated: 'Re-validated.', revalidate_sign_in_popup: 'Sign in with your linked account in the popup.', + revalidate_flow_notice: 'You will be asked to sign in with your linked account when you continue.', color_depth: 'Color Depth', clock_visibility: 'Clock Visibility', close: 'Close', From 8ecd6cd13e5b01908d9625775f5d81a00b47fbc3 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:14:16 -0500 Subject: [PATCH 13/39] dev(oidc): confirm email by default for OIDC --- src/backend/src/services/auth/OIDCService.js | 15 ++++++++++++--- src/backend/src/services/auth/SignupService.js | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/backend/src/services/auth/OIDCService.js b/src/backend/src/services/auth/OIDCService.js index 86e6ef98b..2a4daff03 100644 --- a/src/backend/src/services/auth/OIDCService.js +++ b/src/backend/src/services/auth/OIDCService.js @@ -20,8 +20,10 @@ import jwt from 'jsonwebtoken'; import { username_exists } from '../../helpers.js'; import { generate_identifier } from '../../util/identifier.js'; +import { OutcomeObject } from '../../util/outcomeutil.js'; import BaseService from '../BaseService.js'; import { DB_WRITE } from '../database/consts.js'; +import { CreatedUserOutcome } from './SignupService.js'; const GOOGLE_DISCOVERY_URL = 'https://accounts.google.com/.well-known/openid-configuration'; const GOOGLE_SCOPES = 'openid email profile'; @@ -215,17 +217,24 @@ export class OIDCService extends BaseService { * Create a new Puter user from OIDC claims and link the provider. Delegates to signup_create_new_user. */ async createUserFromOIDC (providerId, claims) { + if ( claims.email_verified === false ) { + // This should never happen; Google always sends verified emails. + const outcome = new OutcomeObject(new CreatedUserOutcome()); + return outcome.fail( + 'Provider did not verify this email address.', + 'oidc.email_not_verified', + ); + } const svc_signup = this.services.get('signup'); const outcome = await svc_signup.create_new_user({ username: await generate_random_username(), email: claims?.email ?? null, password: null, oidc_only: true, + assume_email_ownership: true, }); const { user_id } = outcome.infoObject; - console.log('user_id?', user_id); - if ( outcome.success ) - { + if ( outcome.success ) { await this.linkProviderToUser(user_id, providerId, claims.sub, null); } return outcome; diff --git a/src/backend/src/services/auth/SignupService.js b/src/backend/src/services/auth/SignupService.js index 53e5766c4..224a15068 100644 --- a/src/backend/src/services/auth/SignupService.js +++ b/src/backend/src/services/auth/SignupService.js @@ -23,6 +23,7 @@ export class SignupService extends BaseService { * @param {boolean} [params.temporary] - Whether the user is a temporary user. * @param {boolean} [params.oidc_only] - Whether the user created with OIDC * @param {boolean} [params.send_confirmation_code] - Whether to send a confirmation code instead of a token by email + * @param {boolean} [params.assume_email_ownership] - If true, set email_confirmed=1 without sending verification (e.g. OIDC provider already verified). * @param {string|null} params.username - The username of the user. * @param {string|null} params.email - The email of the user. * @param {string|null} params.password - The password of the user. @@ -33,6 +34,7 @@ export class SignupService extends BaseService { temporary = false, oidc_only = false, send_confirmation_code = false, + assume_email_ownership = false, username = null, email = null, password = null, @@ -171,12 +173,12 @@ export class SignupService extends BaseService { const insert_res = await db.write(`INSERT INTO user ( username, email, clean_email, password, uuid, referrer, - email_confirm_code, email_confirm_token, free_storage, + email_confirm_code, email_confirm_token, email_confirmed, free_storage, referred_by, audit_metadata, signup_ip, signup_ip_forwarded, signup_user_agent, signup_origin, signup_server ) VALUES - (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ // username username, @@ -194,6 +196,8 @@ export class SignupService extends BaseService { email_confirm_code, // email_confirm_token email_confirm_token, + // email_confirmed (1 when assume_email_ownership, else 0) + assume_email_ownership ? 1 : 0, // free_storage this.global_config.storage_capacity, // referred_by @@ -244,10 +248,12 @@ export class SignupService extends BaseService { // }); } - if ( send_confirmation_code ) { - send_email_verification_code(email_confirm_code, email); - } else { - send_email_verification_token(email_confirm_token, email, user_uuid); + if ( ! assume_email_ownership ) { + if ( send_confirmation_code ) { + send_email_verification_code(email_confirm_code, email); + } else { + send_email_verification_token(email_confirm_token, email, user_uuid); + } } // TODO: This is where sending the referral code would From 4d49f5dfa6a0e0e69013a0dfcdcd9034a740eae9 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:50:17 -0500 Subject: [PATCH 14/39] fix: allow `html` property in UIComponentWindow A component was removed and an html property was passed to UIComponentWindow. This makes sense because UIWindow accepts an html property, so rather than update the calling code it made more sense to update UIComponentWindow to be more intuitive. --- src/gui/src/UI/UIComponentWindow.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/gui/src/UI/UIComponentWindow.js b/src/gui/src/UI/UIComponentWindow.js index a10baf909..3edffd6e6 100644 --- a/src/gui/src/UI/UIComponentWindow.js +++ b/src/gui/src/UI/UIComponentWindow.js @@ -18,17 +18,20 @@ */ import UIWindow from './UIWindow.js'; import Placeholder from '../util/Placeholder.js'; +import JustHTML from './Components/JustHTML.js'; /** * @typedef {Object} UIComponentWindowOptions - * @property {Component} A component to render in the window + * @property {Component} [component] A component to render in the window + * @property {string} [html] HTML string to render in the window (uses JustHTML component) */ /** - * Render a UIWindow that contains an instance of Component + * Render a UIWindow that contains an instance of Component or HTML string * @param {UIComponentWindowOptions} options */ export default async function UIComponentWindow (options) { + const component = options.component ?? new JustHTML({ html: options.html ?? '' }); const placeholder = Placeholder(); const win = await UIWindow({ @@ -37,8 +40,8 @@ export default async function UIComponentWindow (options) { body_content: placeholder.html, }); - options.component.attach(placeholder); - options.component.focus(); + component.attach(placeholder); + component.focus(); return win; } From e145f5dcc3a9f87bf8de800ef8ce825a96cb6cfb Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:49:52 -0500 Subject: [PATCH 15/39] dev(oidc): rewrite "Disable 2FA" window In lieu of knowing exactly what happened (probably more than one thing), the "Disable 2FA" window was very broken. It was blank, but then after fixing that all the actions were broken. There wasn't much value in keeping the implementation though, because it was already inconsistent with other flows - instead of fixing what was there it made more sense to re-use the pattern of UIWindowChangeUsername and UIWindowChangeEmail, creating UIWindowDisable2FA. After testing this, it works much better (it actaully works), but there is a caching issue unrelated to the UI implementation. --- src/gui/src/UI/Dashboard/TabSecurity.js | 152 ++----------- src/gui/src/UI/Settings/UITabSecurity.js | 149 +------------ src/gui/src/UI/Settings/UIWindowDisable2FA.js | 207 ++++++++++++++++++ 3 files changed, 233 insertions(+), 275 deletions(-) create mode 100644 src/gui/src/UI/Settings/UIWindowDisable2FA.js diff --git a/src/gui/src/UI/Dashboard/TabSecurity.js b/src/gui/src/UI/Dashboard/TabSecurity.js index 055853755..45244370e 100644 --- a/src/gui/src/UI/Dashboard/TabSecurity.js +++ b/src/gui/src/UI/Dashboard/TabSecurity.js @@ -17,8 +17,7 @@ * along with this program. If not, see . */ -import TeePromise from '../../util/TeePromise.js'; -import UIComponentWindow from '../UIComponentWindow.js'; +import UIWindowDisable2FA from '../Settings/UIWindowDisable2FA.js'; import UIWindow2FASetup from '../UIWindow2FASetup.js'; import UIWindowChangePassword from '../UIWindowChangePassword.js'; import UIWindowManageSessions from '../UIWindowManageSessions.js'; @@ -148,142 +147,25 @@ const TabSecurity = { }); $el_window.find('.dashboard-section-security .disable-2fa').on('click', async function (e) { - let win; - const password_confirm_promise = new TeePromise(); - - function openRevalidatePopup (revalidateUrl, onDone) { - const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); - if ( ! url ) { - onDone && onDone(new Error('No revalidate URL')); - return null; - } - let doneCalled = false; - const hint = $win.find('.disable-2fa-oidc-hint'); - hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); - const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); - const onMessage = (ev) => { - if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; - if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; - if ( doneCalled ) return; - doneCalled = true; - window.removeEventListener('message', onMessage); - hint.hide(); - onDone && onDone(); - }; - window.addEventListener('message', onMessage); - const checkClosed = setInterval(() => { - if ( popup && popup.closed ) { - clearInterval(checkClosed); - window.removeEventListener('message', onMessage); - hint.hide(); - if ( ! doneCalled ) { - doneCalled = true; - onDone && onDone(new Error('Popup closed')); - } - } - }, 300); - return popup; - } - - const doRequest = () => fetch(`${window.api_origin}/user-protected/disable-2fa`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: $win.find('.password-entry').val() }), - }); - - const try_password = async () => { - const resp = await doRequest(); - if ( resp.status === 200 ) { - password_confirm_promise.resolve(true); - $(win).close(); - return; - } - const data = await resp.json().catch(() => ({})); - if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { - openRevalidatePopup(data.revalidate_url, async (err) => { - if ( err ) { - $win.find('.error-message').text(err.message || 'Re-validation required.').show(); - return; - } - const r2 = await doRequest(); - if ( r2.status === 200 ) { - password_confirm_promise.resolve(true); - $(win).close(); - } else { - let message; try { - message = (await r2.json()).message; - } catch (e) { - } - $win.find('.error-message').text(message || i18n('error_unknown_cause')).show(); - } - }); - return; - } - const message = data.message || i18n('error_unknown_cause'); - $win.find('.password-entry').addClass('error'); - $win.find('.error-message').text(message).show(); - }; - - let h = ''; - h += '
'; - h += '
'; - h += `

${i18n('disable_2fa_confirm')}

`; - h += `

${i18n('disable_2fa_instructions')}

`; - h += '
'; - h += '
'; - h += ''; - h += ''; - h += ''; - h += '
'; - h += '
'; - h += ``; - h += ``; - h += '
'; - h += '
'; - - win = await UIComponentWindow({ - html: h, - width: 500, - backdrop: true, - is_resizable: false, - body_css: { - width: 'initial', - 'background-color': 'var(--dashboard-input-background)', - 'backdrop-filter': 'blur(3px)', - padding: '20px', + const { promise } = await UIWindowDisable2FA({ + window_options: { + parent_uuid: $el_window.attr('data-element_uuid'), + backdrop: true, + close_on_backdrop_click: true, + parent_center: true, + stay_on_top: true, + has_head: false, }, }); + const tfa_was_disabled = await promise; - // Set up event listeners - const $win = $(win); - const $password_entry = $win.find('.password-entry'); - - $password_entry.on('keypress', (e) => { - if ( e.which === 13 ) { // Enter key - try_password(); - } - }); - - $win.find('.confirm-disable-2fa').on('click', () => { - try_password(); - }); - - $win.find('.cancel-disable-2fa').on('click', () => { - password_confirm_promise.resolve(false); - $win.close(); - }); - - $password_entry.focus(); - - const ok = await password_confirm_promise; - if ( ! ok ) return; - - $el_window.find('.dashboard-section-security .enable-2fa').show(); - $el_window.find('.dashboard-section-security .disable-2fa').hide(); - $el_window.find('.dashboard-section-security .user-otp-state').text(i18n('two_factor_disabled')); - $el_window.find('.dashboard-section-security .dashboard-settings-card-2fa').removeClass('dashboard-settings-card-success'); - $el_window.find('.dashboard-section-security .dashboard-settings-card-2fa').addClass('dashboard-settings-card-warning'); + if ( tfa_was_disabled ) { + $el_window.find('.dashboard-section-security .enable-2fa').show(); + $el_window.find('.dashboard-section-security .disable-2fa').hide(); + $el_window.find('.dashboard-section-security .user-otp-state').text(i18n('two_factor_disabled')); + $el_window.find('.dashboard-section-security .dashboard-settings-card-2fa').removeClass('dashboard-settings-card-success'); + $el_window.find('.dashboard-section-security .dashboard-settings-card-2fa').addClass('dashboard-settings-card-warning'); + } }); }, }; diff --git a/src/gui/src/UI/Settings/UITabSecurity.js b/src/gui/src/UI/Settings/UITabSecurity.js index 4e5711bbd..342a3e960 100644 --- a/src/gui/src/UI/Settings/UITabSecurity.js +++ b/src/gui/src/UI/Settings/UITabSecurity.js @@ -16,9 +16,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import TeePromise from '../../util/TeePromise.js'; -import UIComponentWindow from '../UIComponentWindow.js'; import UIWindow2FASetup from '../UIWindow2FASetup.js'; +import UIWindowDisable2FA from './UIWindowDisable2FA.js'; export default { id: 'security', @@ -82,146 +81,16 @@ export default { }); $el_window.find('.disable-2fa').on('click', async function (e) { - let win; - const password_confirm_promise = new TeePromise(); + const { promise } = await UIWindowDisable2FA(); + const tfa_was_disabled = await promise; - function openRevalidatePopup ($win, revalidateUrl, onDone) { - const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); - if ( ! url ) { - onDone && onDone(new Error('No revalidate URL')); - return null; - } - let doneCalled = false; - const hint = $win.find('.disable-2fa-oidc-hint'); - hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); - const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); - const onMessage = (ev) => { - if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; - if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; - if ( doneCalled ) return; - doneCalled = true; - window.removeEventListener('message', onMessage); - hint.hide(); - onDone && onDone(); - }; - window.addEventListener('message', onMessage); - const checkClosed = setInterval(() => { - if ( popup && popup.closed ) { - clearInterval(checkClosed); - window.removeEventListener('message', onMessage); - hint.hide(); - if ( ! doneCalled ) { - doneCalled = true; - onDone && onDone(new Error('Popup closed')); - } - } - }, 300); - return popup; + if ( tfa_was_disabled ) { + $el_window.find('.enable-2fa').show(); + $el_window.find('.disable-2fa').hide(); + $el_window.find('.user-otp-state').text(i18n('two_factor_disabled')); + $el_window.find('.settings-card-security').removeClass('settings-card-success'); + $el_window.find('.settings-card-security').addClass('settings-card-warning'); } - - const doRequest = () => fetch(`${window.api_origin}/user-protected/disable-2fa`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - password: win ? $(win).find('.password-entry').val() : '', - }), - }); - - const try_password = async () => { - const resp = await doRequest(); - if ( resp.status === 200 ) { - password_confirm_promise.resolve(true); - $(win).close(); - return; - } - const data = await resp.json().catch(() => ({})); - const $win = $(win); - if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { - openRevalidatePopup($win, data.revalidate_url, async (err) => { - if ( err ) { - $win.find('.error-message').text(err.message || 'Re-validation required.').show(); - return; - } - const r2 = await doRequest(); - if ( r2.status === 200 ) { - password_confirm_promise.resolve(true); - $(win).close(); - } else { - let message; try { - message = (await r2.json()).message; - } catch (e) { - } - $win.find('.error-message').text(message || i18n('error_unknown_cause')).show(); - } - }); - return; - } - $win.find('.password-entry').addClass('error'); - $win.find('.error-message').text(data.message || i18n('error_unknown_cause')).show(); - }; - - const oidc_only = !!(window.user && window.user.oidc_only); - let h = ''; - h += '
'; - h += '
'; - h += `

${i18n('disable_2fa_confirm')}

`; - h += `

${i18n('disable_2fa_instructions')}

`; - if ( oidc_only ) { - h += `

${i18n('revalidate_flow_notice')}

`; - } - h += '
'; - h += '
'; - h += ''; - h += ''; - h += ''; - h += ``; - h += ``; - h += '
'; - h += '
'; - - win = await UIComponentWindow({ - html: h, - width: 500, - backdrop: true, - is_resizable: false, - body_css: { - width: 'initial', - 'background-color': 'rgb(245 247 249)', - 'backdrop-filter': 'blur(3px)', - padding: '20px', - }, - }); - - // Set up event listeners - const $win = $(win); - const $password_entry = $win.find('.password-entry'); - - $password_entry.on('keypress', (e) => { - if ( e.which === 13 ) { // Enter key - try_password(); - } - }); - - $win.find('.confirm-disable-2fa').on('click', () => { - try_password(); - }); - - $win.find('.cancel-disable-2fa').on('click', () => { - password_confirm_promise.resolve(false); - $win.close(); - }); - - $password_entry.focus(); - - const ok = await password_confirm_promise; - if ( ! ok ) return; - - $el_window.find('.enable-2fa').show(); - $el_window.find('.disable-2fa').hide(); - $el_window.find('.user-otp-state').text(i18n('two_factor_disabled')); - $el_window.find('.settings-card-security').removeClass('settings-card-success'); - $el_window.find('.settings-card-security').addClass('settings-card-warning'); }); }, }; diff --git a/src/gui/src/UI/Settings/UIWindowDisable2FA.js b/src/gui/src/UI/Settings/UIWindowDisable2FA.js new file mode 100644 index 000000000..57b52fae2 --- /dev/null +++ b/src/gui/src/UI/Settings/UIWindowDisable2FA.js @@ -0,0 +1,207 @@ +/** + * Copyright (C) 2026-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 . + */ + +import { openRevalidatePopup } from '../../util/openid.js'; +import Placeholder from '../../util/Placeholder.js'; +import TeePromise from '../../util/TeePromise.js'; +import PasswordEntry from '../Components/PasswordEntry.js'; +import UIWindow from '../UIWindow.js'; + +async function UIWindowDisable2FA (options) { + options = options ?? {}; + + const promise = new TeePromise(); + let disabled_successfully = false; + + const password_entry = new PasswordEntry({}); + const place_password_entry = Placeholder(); + + const internal_id = window.uuidv4(); + let h = ''; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += `

${i18n('disable_2fa_instructions')}

`; + h += '
'; + h += '
'; + h += '
'; + h += ``; + h += `${place_password_entry.html}`; + h += '
'; + h += ''; + h += ''; + h += '
'; + h += ``; + h += '
'; + + const el_window = await UIWindow({ + title: i18n('disable_2fa'), + app: 'disable-2fa', + single_instance: true, + icon: null, + uid: null, + is_dir: false, + body_content: h, + has_head: true, + selectable_body: false, + draggable_body: false, + allow_context_menu: false, + is_resizable: false, + is_droppable: false, + init_center: true, + allow_native_ctxmenu: false, + allow_user_select: false, + width: 350, + height: 'auto', + dominant: true, + show_in_taskbar: false, + on_before_exit: async () => { + if ( ! disabled_successfully ) { + promise.resolve(false); + } + return true; + }, + onAppend: function (this_window) { + $(this_window).find('.disable-2fa-password-wrap input').get(0)?.focus({ preventScroll: true }); + const oidc_only = !!(window.user && window.user.oidc_only); + const authRow = $(this_window).find('.disable-2fa-auth-row'); + if ( oidc_only ) { + authRow.find('.disable-2fa-password-wrap').hide(); + const oidcWrap = authRow.find('.disable-2fa-oidc-wrap').show(); + oidcWrap.find('.disable-2fa-oidc-flow-notice').text( + i18n('revalidate_flow_notice') || + 'You will be asked to sign in with your linked account when you continue.', + ); + } else { + authRow.find('.disable-2fa-oidc-wrap').hide(); + } + }, + window_class: 'window-publishWebsite', + body_css: { + width: 'initial', + height: '100%', + 'background-color': 'rgb(245 247 249)', + 'backdrop-filter': 'blur(3px)', + }, + ...options.window_options, + }); + + password_entry.attach(place_password_entry); + + const origin = window.gui_origin || window.api_origin || ''; + const apiUrl = `${origin}/user-protected/disable-2fa`; + let revalidated = false; + + const hint = $(el_window).find('.disable-2fa-oidc-hint'); + const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.'; + + const myOpenRevalidatePopup = async (revalidateUrl) => { + revalidateUrl = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + $(el_window).find('.disable-2fa-btn').addClass('disabled'); + hint.text(REVALIDATE_POPUP_TEXT).show(); + try { + await openRevalidatePopup(revalidateUrl); + } catch (e) { + onError(e.message || 'Authentication failed'); + return; + } finally { + hint.hide(); + } + $(el_window).find('.disable-2fa-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); + }; + + $(el_window).find('.disable-2fa-btn').on('click', async function (e) { + $(el_window).find('.form-success-msg, .form-error-msg').hide(); + + const password = password_entry.get('value'); + const oidc_only = !!(window.user && window.user.oidc_only); + + if ( !oidc_only && !password ) { + $(el_window).find('.form-error-msg').html(i18n('all_fields_required')); + $(el_window).find('.form-error-msg').fadeIn(); + return; + } + + if ( oidc_only && !revalidated && !password ) { + await myOpenRevalidatePopup(); + + const res = await doSubmit({ password: undefined }); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + if ( res.ok ) onSuccess(); + else onError(data.message || 'Request failed'); + return; + } + $(el_window).find('.form-error-msg').hide(); + $(el_window).find('.disable-2fa-btn').addClass('disabled'); + $(el_window).find('.disable-2fa-password-wrap input').attr('disabled', true); + + let res = await doSubmit({ password }); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + + if ( res.ok ) { + onSuccess(); + return; + } + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + await myOpenRevalidatePopup(data.revalidate_url); + const r = await doSubmit({ password: undefined }); + if ( r.ok ) onSuccess(); + else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed')); + return; + } + onError(data.message || 'Request failed'); + }); + + function doSubmit ({ password }) { + return fetch(apiUrl, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + password: password !== undefined && password !== '' ? password : undefined, + }), + }); + } + + function onError (message) { + $(el_window).find('.form-error-msg').html(html_encode(message)); + $(el_window).find('.form-error-msg').fadeIn(); + $(el_window).find('.disable-2fa-btn').removeClass('disabled'); + $(el_window).find('.disable-2fa-password-wrap input').attr('disabled', false); + } + + function onSuccess () { + disabled_successfully = true; + $(el_window).find('.form-success-msg').html(i18n('two_factor_disabled')); + $(el_window).find('.form-success-msg').fadeIn(); + if ( window.user ) window.user.otp = false; + $(el_window).find('.disable-2fa-btn').removeClass('disabled'); + $(el_window).find('.disable-2fa-password-wrap input').attr('disabled', false); + promise.resolve(true); + $(el_window).close(); + } + + return { promise }; +} + +export default UIWindowDisable2FA; From 298f1cdb42e94c3905b5e7ffe4a4f552cf9cb3de Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:15:47 -0500 Subject: [PATCH 16/39] fix: incorrect accessor reference in OIDCService During development a property named `success` was inverted to a property named `failed` which resulted in an incorrect accessor reference with a referring piece of code that wasn't updated. This is type error. --- src/backend/src/services/auth/OIDCService.js | 2 +- src/backend/src/util/outcomeutil.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/backend/src/services/auth/OIDCService.js b/src/backend/src/services/auth/OIDCService.js index 2a4daff03..e72c9506d 100644 --- a/src/backend/src/services/auth/OIDCService.js +++ b/src/backend/src/services/auth/OIDCService.js @@ -234,7 +234,7 @@ export class OIDCService extends BaseService { assume_email_ownership: true, }); const { user_id } = outcome.infoObject; - if ( outcome.success ) { + if ( outcome.succeeded ) { await this.linkProviderToUser(user_id, providerId, claims.sub, null); } return outcome; diff --git a/src/backend/src/util/outcomeutil.js b/src/backend/src/util/outcomeutil.js index df64a6ccc..2aff7b1f7 100644 --- a/src/backend/src/util/outcomeutil.js +++ b/src/backend/src/util/outcomeutil.js @@ -7,6 +7,9 @@ export class OutcomeObject { fields = {}; ended = false; infoObject; + get succeeded () { + return this.ended && !this.failed; + } constructor (infoObject) { this.failed = true; this.userMessageFields = {}; From 2cdc211b29b2ced81c5b7ef6d253de96e02cf8df Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:52:20 -0500 Subject: [PATCH 17/39] fix: incorrect parameters in UIWindowChangePassword --- src/gui/src/UI/UIWindowChangePassword.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/src/UI/UIWindowChangePassword.js b/src/gui/src/UI/UIWindowChangePassword.js index 528c01e4f..2fb11ac71 100644 --- a/src/gui/src/UI/UIWindowChangePassword.js +++ b/src/gui/src/UI/UIWindowChangePassword.js @@ -171,7 +171,7 @@ async function UIWindowChangePassword (options) { $(el_window).find('.change-password-btn').addClass('disabled'); $(el_window).find('.current-password, .new-password, .confirm-new-password').attr('disabled', true); - let res = await doSubmit(current_password); + let res = await doSubmit({ current_password, new_password }); const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); if ( res.ok ) { From b5a332381146316e3c16e6705a190933ee608195 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:14:41 -0500 Subject: [PATCH 18/39] fix: incorrect parameters in UIWindowChangeEmail --- src/gui/src/UI/Settings/UIWindowChangeEmail.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/src/UI/Settings/UIWindowChangeEmail.js b/src/gui/src/UI/Settings/UIWindowChangeEmail.js index a09e66cb1..f4835e807 100644 --- a/src/gui/src/UI/Settings/UIWindowChangeEmail.js +++ b/src/gui/src/UI/Settings/UIWindowChangeEmail.js @@ -164,7 +164,7 @@ async function UIWindowChangeEmail (options) { } if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { await myOpenRevalidatePopup(data.revalidate_url); - const r = await doSubmit(); + const r = await doSubmit({ new_email }); if ( r.ok ) onSuccess(); else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed')); return; From 42d3f9e816df520337f845be464fdbe2acf88420 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:58:31 -0500 Subject: [PATCH 19/39] fix(oidc): http-only cookie sync for switch user --- src/backend/src/services/auth/AuthService.js | 28 ++++++++++++++++++++ src/gui/src/helpers.js | 21 +++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index 1881c3ed1..68eab4518 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -714,6 +714,34 @@ class AuthService extends BaseService { return res.json({ token: gui_token }); }, }).attach(app); + + // Sync HTTP-only session cookie to the user implied by the request's auth token. + // Used when switching users in the UI: client sends Authorization with the new user's + // GUI token; we set the session cookie so cookie-based (e.g. user-protected) requests match. + Endpoint({ + route: '/session/sync-cookie', + methods: ['GET'], + mw: [configurable_auth()], + handler: async (req, res) => { + if ( ! req.user ) { + return res.status(401).end(); + } + const actor = Context.get('actor'); + if ( !(actor.type instanceof UserActorType) || !actor.type.session ) { + return res.status(400).end(); + } + const session_token = svc_auth.create_session_token_for_session( + actor.type.user, + actor.type.session, + ); + res.cookie(config.cookie_name, session_token, { + sameSite: 'none', + secure: true, + httpOnly: true, + }); + return res.status(204).end(); + }, + }).attach(app); } } diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index 62fcdeac6..539582e4e 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -461,6 +461,27 @@ window.update_auth_data = async (auth_token, user, api_origin) => { window.auth_token = auth_token; localStorage.setItem('auth_token', auth_token); + // Set http-only session cookie when user is changing. + // This ensures user-protected endpoints, which only refer to the http-only cookie, + // act on the intended user. + // Only the server can set this cookie, so we call the `/session/sync-cookie` endpoint. + const userChanging = !window.user || window.user.uuid !== user.uuid; + if ( userChanging && auth_token && (window.gui_origin || window.location?.origin) ) { + try { + const origin = window.gui_origin || window.location.origin; + await fetch(`${origin}/session/sync-cookie`, { + method: 'GET', + credentials: 'include', + headers: { Authorization: `Bearer ${auth_token}` }, + }); + } catch (e) { + console.error('Failed to sync session cookie:', e); + await UIAlert({ + message: `Failed to sync session cookie: ${ e.message}`, + }); + } + } + if ( api_origin ) { window.api_origin = api_origin; localStorage.setItem('api_origin', api_origin); From 2b802143fc67b2112a08c944daeb36098c939c60 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:20:03 -0500 Subject: [PATCH 20/39] fix(oidc): add missing awaits --- src/gui/src/UI/UIWindowChangeUsername.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gui/src/UI/UIWindowChangeUsername.js b/src/gui/src/UI/UIWindowChangeUsername.js index c54babfd9..557c31b93 100644 --- a/src/gui/src/UI/UIWindowChangeUsername.js +++ b/src/gui/src/UI/UIWindowChangeUsername.js @@ -128,7 +128,7 @@ async function UIWindowChangeUsername (options) { } if ( oidc_only && !revalidated && !password ) { $(el_window).find('.change-username-btn').addClass('disabled'); - myOpenRevalidatePopup(); + await myOpenRevalidatePopup(); const res = await doSubmit(); const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); @@ -148,7 +148,7 @@ async function UIWindowChangeUsername (options) { return; } if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { - myOpenRevalidatePopup(data.revalidate_url); + await myOpenRevalidatePopup(data.revalidate_url); const r = await doSubmit(); if ( r.ok ) onSuccess(); else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed')); From d0c2e9b7fc4c1e6a3a26ba36865ae45a2f5e1675 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:25:15 -0500 Subject: [PATCH 21/39] fix(oidc): rate-limit identity for username --- src/backend/src/routers/user-protected/change-username.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/src/routers/user-protected/change-username.js b/src/backend/src/routers/user-protected/change-username.js index ece0c5fc7..03d06383a 100644 --- a/src/backend/src/routers/user-protected/change-username.js +++ b/src/backend/src/routers/user-protected/change-username.js @@ -46,7 +46,7 @@ module.exports = { } const svc_edgeRateLimit = req.services.get('edge-rate-limit'); - if ( ! svc_edgeRateLimit.check('change-email-start') ) { + if ( ! svc_edgeRateLimit.check('change-username-start') ) { return res.status(429).send('Too many requests.'); } From a2b919328757c464d9211ec37009c3d974cc205d Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:18:08 -0500 Subject: [PATCH 22/39] clean(oidc): remove temporary debugging logs These logs were temporary. I have a pre-merge TODO list item that says to remove these. They were caught in review so I'm removing them early. --- src/backend/src/middleware/configurable_auth.js | 1 - src/backend/src/routers/auth/oidc.js | 7 ------- src/backend/src/routers/logout.js | 2 -- 3 files changed, 10 deletions(-) diff --git a/src/backend/src/middleware/configurable_auth.js b/src/backend/src/middleware/configurable_auth.js index 49d2e9373..9880d60e1 100644 --- a/src/backend/src/middleware/configurable_auth.js +++ b/src/backend/src/middleware/configurable_auth.js @@ -128,7 +128,6 @@ const configurable_auth = options => async (req, res, next) => { const tokenPreview = typeof token === 'string' && token.length > 20 ? `${token.slice(0, 12)}...${token.slice(-8)}` : '(short)'; - console.log(`[configurable_auth] token used for Actor: [${req.url}] source=${tokenSource}, decoded.type=${tokenType}, preview=${tokenPreview}`); } let actor; diff --git a/src/backend/src/routers/auth/oidc.js b/src/backend/src/routers/auth/oidc.js index bf74ad7d9..c18517205 100644 --- a/src/backend/src/routers/auth/oidc.js +++ b/src/backend/src/routers/auth/oidc.js @@ -28,7 +28,6 @@ const REVALIDATION_EXPIRY_SEC = 300; // 5 minutes /** If Accept includes text/html, set session cookie and redirect to app; otherwise send JSON. */ const finishOidcSuccess_ = async (req, res, user, stateDecoded) => { - console.log('okay finishOidSuccess_ is happening'); const svc_auth = req.services.get('auth'); const { session, token: session_token } = await svc_auth.create_session_token(user, { req }); res.cookie(config.cookie_name, session_token, { @@ -36,12 +35,8 @@ const finishOidcSuccess_ = async (req, res, user, stateDecoded) => { secure: true, httpOnly: true, }); - console.log('what are these values?', { - stateDecoded, - }); let target = stateDecoded.redirect_uri || config.origin || '/'; const origin = config.origin || ''; - console.log('okay what\'s target though?', { target, origin }); if ( target && origin && !target.startsWith(origin) ) { target = origin; } @@ -169,11 +164,9 @@ router.get('/auth/oidc/callback/signup', async (req, res) => { } const outcome = await svc_oidc.createUserFromOIDC(provider, userinfo); if ( outcome.failed ) { - console.log('it looks like the outcome failed...'); return res.status(400).send(outcome.userMessage); } const user = await get_user({ id: outcome.infoObject.user_id }); - console.log('got user????', user); return await finishOidcSuccess_(req, res, user, stateDecoded); }); diff --git a/src/backend/src/routers/logout.js b/src/backend/src/routers/logout.js index da3ca586a..84cbe275b 100644 --- a/src/backend/src/routers/logout.js +++ b/src/backend/src/routers/logout.js @@ -51,9 +51,7 @@ router.post('/logout', auth, express.json(), async (req, res, next) => { //--------------------------------------------------------- // DANGER ZONE: delete temp user and all its data //--------------------------------------------------------- - console.log('wait... what are these?', req.user.password, req.user.email); if ( req.user.password === null && req.user.email === null ) { - console.log('ACTUALLY DELETING A USER'); const { deleteUser } = require('../helpers'); deleteUser(req.user.id); } From 7858f5ba3ed642d043d13f6eec5bbd903ecbe94f Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:34:52 -0500 Subject: [PATCH 23/39] fix(oidc): add error log for QR login flow --- src/backend/src/modules/web/WebServerService.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/backend/src/modules/web/WebServerService.js b/src/backend/src/modules/web/WebServerService.js index 7db1ad6a4..c02d542e5 100644 --- a/src/backend/src/modules/web/WebServerService.js +++ b/src/backend/src/modules/web/WebServerService.js @@ -359,8 +359,9 @@ class WebServerService extends BaseService { secure: true, httpOnly: true, }); - } catch ( _e ) { - // Invalid or expired token; do not set cookie + } catch ( e ) { + console.log('query auth token (QR Code login probably) failed'); + console.error(e); } next(); }); From 8d18ee527cddf95c8154cd89a872748814becb0b Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:35:58 -0500 Subject: [PATCH 24/39] refactor(oidc): address review comment https://github.com/HeyPuter/puter/pull/2460/changes#r2819861213 --- src/backend/src/routers/auth/oidc.js | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/backend/src/routers/auth/oidc.js b/src/backend/src/routers/auth/oidc.js index c18517205..a954815a0 100644 --- a/src/backend/src/routers/auth/oidc.js +++ b/src/backend/src/routers/auth/oidc.js @@ -26,21 +26,16 @@ const { get_user } = require('../../helpers'); const REVALIDATION_COOKIE_NAME = 'puter_revalidation'; const REVALIDATION_EXPIRY_SEC = 300; // 5 minutes -/** If Accept includes text/html, set session cookie and redirect to app; otherwise send JSON. */ +/** Returns { session_token, target } for the caller to set cookie and redirect. */ const finishOidcSuccess_ = async (req, res, user, stateDecoded) => { const svc_auth = req.services.get('auth'); - const { session, token: session_token } = await svc_auth.create_session_token(user, { req }); - res.cookie(config.cookie_name, session_token, { - sameSite: 'none', - secure: true, - httpOnly: true, - }); + const { token: session_token } = await svc_auth.create_session_token(user, { req }); let target = stateDecoded.redirect_uri || config.origin || '/'; const origin = config.origin || ''; if ( target && origin && !target.startsWith(origin) ) { target = origin; } - return res.redirect(302, target); + return { session_token, target }; }; /** Exchange code for tokens, get userinfo; returns { provider, userinfo, stateDecoded } or sends error and returns null. */ @@ -141,7 +136,13 @@ router.get('/auth/oidc/callback/login', async (req, res) => { if ( user.suspended ) { return res.status(401).send('This account is suspended.'); } - return await finishOidcSuccess_(req, res, user, stateDecoded); + const { session_token, target } = await finishOidcSuccess_(req, res, user, stateDecoded); + res.cookie(config.cookie_name, session_token, { + sameSite: 'none', + secure: true, + httpOnly: true, + }); + return res.redirect(302, target); }); // GET /auth/oidc/callback/signup - signup only: create new account or abort. Never logs in to existing account. @@ -167,7 +168,13 @@ router.get('/auth/oidc/callback/signup', async (req, res) => { return res.status(400).send(outcome.userMessage); } const user = await get_user({ id: outcome.infoObject.user_id }); - return await finishOidcSuccess_(req, res, user, stateDecoded); + const { session_token, target } = await finishOidcSuccess_(req, res, user, stateDecoded); + res.cookie(config.cookie_name, session_token, { + sameSite: 'none', + secure: true, + httpOnly: true, + }); + return res.redirect(302, target); }); // GET /auth/oidc/callback/revalidate - re-validate identity for protected actions (e.g. change username). Sets short-lived cookie and redirects. From ccecf0a86eb6a2886485e06e98d718274202fa05 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:55:23 -0500 Subject: [PATCH 25/39] fix(oidc): remove generated source file I forgot that our transpile tooling requires manually gitignoring individual javascript files because we don't actually create a full generated build of backend. --- src/backend/src/util/.gitignore | 1 + src/backend/src/util/outcomeutil.js | 35 ----------------------------- 2 files changed, 1 insertion(+), 35 deletions(-) create mode 100644 src/backend/src/util/.gitignore delete mode 100644 src/backend/src/util/outcomeutil.js diff --git a/src/backend/src/util/.gitignore b/src/backend/src/util/.gitignore new file mode 100644 index 000000000..7493f97ed --- /dev/null +++ b/src/backend/src/util/.gitignore @@ -0,0 +1 @@ +!outcomeutil.js diff --git a/src/backend/src/util/outcomeutil.js b/src/backend/src/util/outcomeutil.js deleted file mode 100644 index 2aff7b1f7..000000000 --- a/src/backend/src/util/outcomeutil.js +++ /dev/null @@ -1,35 +0,0 @@ -export class OutcomeObject { - userMessage = null; - userMessageKey = null; - userMessageFields = {}; - failed = false; - messages = []; - fields = {}; - ended = false; - infoObject; - get succeeded () { - return this.ended && !this.failed; - } - constructor (infoObject) { - this.failed = true; - this.userMessageFields = {}; - this.infoObject = infoObject; - } - log (text, fields) { - this.messages.push({ text, fields }); - } - fail (message, i18nKey, fields = {}) { - this.userMessage = message; - this.userMessageKey = i18nKey; - this.userMessageFields = fields; - this.ended = true; - this.failed = true; - return this; - } - success () { - this.ended = true; - this.failed = false; - return this; - } -} -//# sourceMappingURL=outcomeutil.js.map \ No newline at end of file From 7ca0fe2ac4114e505ac895c08da3786398c45129 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:58:43 -0500 Subject: [PATCH 26/39] clean: remove commented code --- src/gui/src/UI/UIWindowChangePassword.js | 36 ------------------------ 1 file changed, 36 deletions(-) diff --git a/src/gui/src/UI/UIWindowChangePassword.js b/src/gui/src/UI/UIWindowChangePassword.js index 2fb11ac71..d7da2fd1d 100644 --- a/src/gui/src/UI/UIWindowChangePassword.js +++ b/src/gui/src/UI/UIWindowChangePassword.js @@ -188,42 +188,6 @@ async function UIWindowChangePassword (options) { onError(data.message || res.statusText || 'Request failed'); }); - // function openRevalidatePopup (revalidateUrl, onDone) { - // const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url); - // if ( ! url ) { - // onDone && onDone(new Error('No revalidate URL')); - // return null; - // } - // let doneCalled = false; - // const hint = $(el_window).find('.change-password-oidc-hint'); - // hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show(); - // const popup = window.open(url, 'puter-revalidate', 'width=500,height=600'); - // const onMessage = (ev) => { - // if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; - // if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; - // if ( doneCalled ) return; - // doneCalled = true; - // window.removeEventListener('message', onMessage); - // revalidated = true; - // hint.hide(); - // $(el_window).find('.change-password-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); - // $(el_window).find('.change-password-revalidate-btn').hide(); - // onDone && onDone(); - // }; - // window.addEventListener('message', onMessage); - // const checkClosed = setInterval(() => { - // if ( popup && popup.closed ) { - // clearInterval(checkClosed); - // window.removeEventListener('message', onMessage); - // hint.hide(); - // if ( ! doneCalled ) { - // doneCalled = true; - // onDone && onDone(new Error('Popup closed')); - // } - // } - // }, 300); - // return popup; - // } function doSubmit ({ new_password, current_password }) { return fetch(apiUrl, { method: 'POST', From 720277c9bd790a0ea4f2a5b5ad06d77376abda3e Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:55:51 -0500 Subject: [PATCH 27/39] style(oidc): migrate cjs to esm --- src/backend/src/routers/auth/oidc.js | 25 +++++++++---------- .../src/routers/signup_create_new_user.js | 11 ++++---- src/backend/src/services/PuterAPIService.js | 2 +- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/backend/src/routers/auth/oidc.js b/src/backend/src/routers/auth/oidc.js index a954815a0..039cac761 100644 --- a/src/backend/src/routers/auth/oidc.js +++ b/src/backend/src/routers/auth/oidc.js @@ -16,12 +16,11 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -'use strict'; -const express = require('express'); -const router = new express.Router(); -const config = require('../../config'); -const jwt = require('jsonwebtoken'); -const { get_user } = require('../../helpers'); +import express from 'express'; +const router = express.Router(); +import config from '../../config.js'; +import jwt from 'jsonwebtoken'; +import { get_user, subdomain } from '../../helpers.js'; const REVALIDATION_COOKIE_NAME = 'puter_revalidation'; const REVALIDATION_EXPIRY_SEC = 300; // 5 minutes @@ -68,7 +67,7 @@ const oidcCallbackPreamble_ = async (req, res, callbackRedirectUri) => { // GET /auth/oidc/providers - list enabled provider ids for frontend router.get('/auth/oidc/providers', async (req, res) => { - if ( require('../../helpers').subdomain(req) !== 'api' ) { + if ( subdomain(req) !== 'api' ) { return res.status(404).end(); } const svc_oidc = req.services.get('oidc'); @@ -78,7 +77,7 @@ router.get('/auth/oidc/providers', async (req, res) => { // GET /auth/oidc/:provider/start - redirect to IdP authorization router.get('/auth/oidc/:provider/start', async (req, res) => { - if ( require('../../helpers').subdomain(req) !== '' ) { + if ( subdomain(req) !== '' ) { return res.status(404).end(); } const svc_edgeRateLimit = req.services.get('edge-rate-limit'); @@ -117,7 +116,7 @@ router.get('/auth/oidc/:provider/start', async (req, res) => { // GET /auth/oidc/callback/login - login only: existing account or abort. Never creates a user. router.get('/auth/oidc/callback/login', async (req, res) => { - if ( require('../../helpers').subdomain(req) !== '' ) { + if ( subdomain(req) !== '' ) { return res.status(404).end(); } const svc_edgeRateLimit = req.services.get('edge-rate-limit'); @@ -147,7 +146,7 @@ router.get('/auth/oidc/callback/login', async (req, res) => { // GET /auth/oidc/callback/signup - signup only: create new account or abort. Never logs in to existing account. router.get('/auth/oidc/callback/signup', async (req, res) => { - if ( require('../../helpers').subdomain(req) !== '' ) { + if ( subdomain(req) !== '' ) { return res.status(404).end(); } const svc_edgeRateLimit = req.services.get('edge-rate-limit'); @@ -179,7 +178,7 @@ router.get('/auth/oidc/callback/signup', async (req, res) => { // GET /auth/oidc/callback/revalidate - re-validate identity for protected actions (e.g. change username). Sets short-lived cookie and redirects. router.get('/auth/oidc/callback/revalidate', async (req, res) => { - if ( require('../../helpers').subdomain(req) !== '' ) { + if ( subdomain(req) !== '' ) { return res.status(404).end(); } const svc_edgeRateLimit = req.services.get('edge-rate-limit'); @@ -217,7 +216,7 @@ router.get('/auth/oidc/callback/revalidate', async (req, res) => { // GET /auth/revalidate-done - landing page after OIDC revalidate; posts to opener and closes (for popup flow). router.get('/auth/revalidate-done', (req, res) => { - if ( require('../../helpers').subdomain(req) !== '' ) { + if ( subdomain(req) !== '' ) { return res.status(404).end(); } const origin = config.origin || ''; @@ -235,4 +234,4 @@ if (window.opener) {

Re-validated. Closing…

`); }); -module.exports = router; +export default router; diff --git a/src/backend/src/routers/signup_create_new_user.js b/src/backend/src/routers/signup_create_new_user.js index fa577953a..902b82fbe 100644 --- a/src/backend/src/routers/signup_create_new_user.js +++ b/src/backend/src/routers/signup_create_new_user.js @@ -16,11 +16,10 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -'use strict'; -const config = require('../config'); -const { DB_WRITE } = require('../services/database/consts'); -const { generate_identifier } = require('../util/identifier'); -const { v4: uuidv4 } = require('uuid'); +import config from '../config.js'; +import { DB_WRITE } from '../services/database/consts.js'; +import { generate_identifier } from '../util/identifier.js'; +import { v4 as uuidv4 } from 'uuid'; /** * Create a new user for signup. Common behavior shared by POST /signup and OIDC signup. @@ -124,4 +123,4 @@ async function signup_create_new_user (services, options) { return user; } -module.exports = signup_create_new_user; +export default signup_create_new_user; diff --git a/src/backend/src/services/PuterAPIService.js b/src/backend/src/services/PuterAPIService.js index a3e82406f..e7e3f4353 100644 --- a/src/backend/src/services/PuterAPIService.js +++ b/src/backend/src/services/PuterAPIService.js @@ -70,7 +70,7 @@ class PuterAPIService extends BaseService { // app.use(require('../routers/get-launch-apps')) app.use(require('../routers/itemMetadata')); app.use(require('../routers/login')); - app.use(require('../routers/auth/oidc')); + app.use(require('../routers/auth/oidc').default); app.use(require('../routers/logout')); app.use(require('../routers/open_item')); app.use(require('../routers/passwd')); From 16f2f5bf5f61f2d4ec50a07d06795cbcc48df6ee Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:21:29 -0500 Subject: [PATCH 28/39] style(oidc): address PR rev on oidcCallbackPreamble_ --- src/backend/src/routers/auth/oidc.js | 55 ++++++++++++++++++---------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/src/backend/src/routers/auth/oidc.js b/src/backend/src/routers/auth/oidc.js index 039cac761..511cb523a 100644 --- a/src/backend/src/routers/auth/oidc.js +++ b/src/backend/src/routers/auth/oidc.js @@ -25,6 +25,18 @@ import { get_user, subdomain } from '../../helpers.js'; const REVALIDATION_COOKIE_NAME = 'puter_revalidation'; const REVALIDATION_EXPIRY_SEC = 300; // 5 minutes +const MISSING_CODE_OR_STATE = Symbol('MISSING_CODE_OR_STATE'); +const INVALID_OR_EXPIRED_STATE = Symbol('INVALID_OR_EXPIRED_STATE'); +const TOKEN_EXCHANGE_FAILED = Symbol('TOKEN_EXCHANGE_FAILED'); +const COULD_NOT_GET_USER_INFO = Symbol('COULD_NOT_GET_USER_INFO'); + +const OIDC_CALLBACK_ERROR_RESPONSES = { + [MISSING_CODE_OR_STATE]: { status: 400, message: 'Missing code or state.' }, + [INVALID_OR_EXPIRED_STATE]: { status: 400, message: 'Invalid or expired state.' }, + [TOKEN_EXCHANGE_FAILED]: { status: 401, message: 'Token exchange failed.' }, + [COULD_NOT_GET_USER_INFO]: { status: 401, message: 'Could not get user info.' }, +}; + /** Returns { session_token, target } for the caller to set cookie and redirect. */ const finishOidcSuccess_ = async (req, res, user, stateDecoded) => { const svc_auth = req.services.get('auth'); @@ -37,30 +49,26 @@ const finishOidcSuccess_ = async (req, res, user, stateDecoded) => { return { session_token, target }; }; -/** Exchange code for tokens, get userinfo; returns { provider, userinfo, stateDecoded } or sends error and returns null. */ -const oidcCallbackPreamble_ = async (req, res, callbackRedirectUri) => { +/** Exchange code for tokens, get userinfo. Returns { provider, userinfo, stateDecoded } or { error } (symbol). */ +const processOIDCCallbackRequest_ = async (req, callbackRedirectUri) => { const svc_oidc = req.services.get('oidc'); const code = req.query.code; const state = req.query.state; if ( !code || !state ) { - res.status(400).send('Missing code or state.'); - return null; + return { error: MISSING_CODE_OR_STATE }; } const stateDecoded = svc_oidc.verifyState(state); if ( !stateDecoded || !stateDecoded.provider ) { - res.status(400).send('Invalid or expired state.'); - return null; + return { error: INVALID_OR_EXPIRED_STATE }; } const provider = stateDecoded.provider; const tokens = await svc_oidc.exchangeCodeForTokens(provider, code, callbackRedirectUri); if ( !tokens || !tokens.access_token ) { - res.status(401).send('Token exchange failed.'); - return null; + return { error: TOKEN_EXCHANGE_FAILED }; } const userinfo = await svc_oidc.getUserInfo(provider, tokens.access_token); if ( !userinfo || !userinfo.sub ) { - res.status(401).send('Could not get user info.'); - return null; + return { error: COULD_NOT_GET_USER_INFO }; } return { provider, userinfo, stateDecoded }; }; @@ -125,9 +133,12 @@ router.get('/auth/oidc/callback/login', async (req, res) => { } const svc_oidc = req.services.get('oidc'); const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('login'); - const preamble = await oidcCallbackPreamble_(req, res, callbackRedirectUri); - if ( ! preamble ) return; - const { provider, userinfo, stateDecoded } = preamble; + const result = await processOIDCCallbackRequest_(req, callbackRedirectUri); + if ( result.error ) { + const { status, message } = OIDC_CALLBACK_ERROR_RESPONSES[result.error]; + return res.status(status).send(message); + } + const { provider, userinfo, stateDecoded } = result; const user = await svc_oidc.findUserByProviderSub(provider, userinfo.sub); if ( ! user ) { return res.status(400).send('No account found. Sign up first.'); @@ -155,9 +166,12 @@ router.get('/auth/oidc/callback/signup', async (req, res) => { } const svc_oidc = req.services.get('oidc'); const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('signup'); - const preamble = await oidcCallbackPreamble_(req, res, callbackRedirectUri); - if ( ! preamble ) return; - const { provider, userinfo, stateDecoded } = preamble; + const result = await processOIDCCallbackRequest_(req, callbackRedirectUri); + if ( result.error ) { + const { status, message } = OIDC_CALLBACK_ERROR_RESPONSES[result.error]; + return res.status(status).send(message); + } + const { provider, userinfo, stateDecoded } = result; const existingUser = await svc_oidc.findUserByProviderSub(provider, userinfo.sub); if ( existingUser ) { return res.status(400).send('Account already exists. Log in instead.'); @@ -187,9 +201,12 @@ router.get('/auth/oidc/callback/revalidate', async (req, res) => { } const svc_oidc = req.services.get('oidc'); const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('revalidate'); - const preamble = await oidcCallbackPreamble_(req, res, callbackRedirectUri); - if ( ! preamble ) return; - const { provider, userinfo, stateDecoded } = preamble; + const result = await processOIDCCallbackRequest_(req, callbackRedirectUri); + if ( result.error ) { + const { status, message } = OIDC_CALLBACK_ERROR_RESPONSES[result.error]; + return res.status(status).send(message); + } + const { provider, userinfo, stateDecoded } = result; if ( stateDecoded.flow !== 'revalidate' || stateDecoded.user_id == null ) { return res.status(400).send('Invalid revalidate state.'); } From c97a499bc9dc0bcc03ede509b40245a7cf608763 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:16:25 -0500 Subject: [PATCH 29/39] style(oidc): private members in OIDCService --- src/backend/src/services/auth/OIDCService.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/backend/src/services/auth/OIDCService.js b/src/backend/src/services/auth/OIDCService.js index e72c9506d..28d5dee60 100644 --- a/src/backend/src/services/auth/OIDCService.js +++ b/src/backend/src/services/auth/OIDCService.js @@ -48,10 +48,12 @@ export class OIDCService extends BaseService { jwt, }; + #googleDiscovery; + async _init () { this.db = await this.services.get('database').get(DB_WRITE, 'auth'); this.providers = this.config.providers ?? {}; - this._googleDiscovery = null; + this.#googleDiscovery = null; } /** @@ -66,7 +68,7 @@ export class OIDCService extends BaseService { return null; } if ( providerId === 'google' ) { - const discovery = await this._getGoogleDiscovery_(); + const discovery = await this.#getGoogleDiscovery(); if ( ! discovery ) return null; return { client_id: raw.client_id, @@ -86,13 +88,13 @@ export class OIDCService extends BaseService { return null; } - async _getGoogleDiscovery_ () { - if ( this._googleDiscovery ) return this._googleDiscovery; + async #getGoogleDiscovery () { + if ( this.#googleDiscovery ) return this.#googleDiscovery; try { const res = await fetch(GOOGLE_DISCOVERY_URL); if ( ! res.ok ) return null; - this._googleDiscovery = await res.json(); - return this._googleDiscovery; + this.#googleDiscovery = await res.json(); + return this.#googleDiscovery; } catch ( e ) { this.log?.warn?.('OIDC: Google discovery fetch failed', e); return null; From b5d719e11019794e45f2f02f5a3e74aa50ee1970 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:33:30 -0500 Subject: [PATCH 30/39] lint: [+] no-useless-computed-key:error --- eslint.config.js | 1 + src/backend/src/ExtensionService.js | 8 ++-- src/backend/src/Kernel.js | 6 +-- .../src/filesystem/hl_operations/hl_stat.js | 2 +- .../src/modules/apps/AppIconService.js | 8 ++-- .../src/modules/apps/OldAppNameService.js | 2 +- .../modules/apps/RecommendedAppsService.js | 2 +- .../src/modules/broadcast/BroadcastService.js | 4 +- .../captcha/services/CaptchaService.js | 2 +- src/backend/src/modules/core/AlarmService.js | 2 +- .../src/modules/core/ExpectationService.js | 2 +- src/backend/src/modules/core/LogService.js | 2 +- src/backend/src/modules/core/PagerService.js | 2 +- .../src/modules/core/ParameterService.js | 2 +- .../src/modules/data-access/AppService.js | 2 +- .../development/LocalTerminalService.js | 4 +- .../src/modules/domain/TXTVerifyService.js | 2 +- .../EntityStoreInterfaceService.js | 2 +- .../kvstore/KVStoreInterfaceService.js | 2 +- .../src/modules/puterfs/MountpointService.js | 2 +- .../src/modules/puterfs/SizeService.js | 2 +- .../modules/selfhosted/DefaultUserService.js | 4 +- .../modules/selfhosted/DevWatcherService.js | 2 +- .../selfhosted/ServeSingeFileService.js | 2 +- .../selfhosted/ServeStaticFilesService.js | 2 +- .../src/modules/template/TemplateService.js | 8 ++-- .../test-drivers/TestAssetHostService.js | 2 +- .../modules/test-drivers/TestImageService.js | 6 +-- .../src/modules/web/SocketioService.js | 2 +- .../src/modules/web/WebServerService.js | 8 ++-- src/backend/src/om/proptypes/__all__.js | 6 +-- src/backend/src/routers/hosting/puter-site.js | 2 +- src/backend/src/services/BootScriptService.js | 4 +- src/backend/src/services/ChatAPIService.js | 2 +- src/backend/src/services/CommandService.js | 2 +- .../src/services/ContextInitService.js | 2 +- .../src/services/EntityStoreService.js | 4 +- src/backend/src/services/EntriService.js | 6 +-- src/backend/src/services/EventService.js | 2 +- .../src/services/FilesystemAPIService.js | 2 +- src/backend/src/services/HelloWorldService.js | 4 +- src/backend/src/services/KernelInfoService.js | 2 +- .../src/services/LocalDiskStorageService.js | 2 +- .../MakeProdDebuggingLessAwfulService.js | 2 +- .../src/services/NotificationService.js | 2 +- .../src/services/PermissionAPIService.js | 2 +- src/backend/src/services/PuterAPIService.js | 2 +- .../src/services/PuterHomepageService.js | 6 +-- .../src/services/PuterVersionService.js | 2 +- .../services/RefreshAssociationsService.js | 2 +- src/backend/src/services/RegistryService.js | 2 +- .../src/services/RequestMeasureService.js | 2 +- src/backend/src/services/SNSService.js | 2 +- src/backend/src/services/SUService.js | 2 +- src/backend/src/services/ServeGUIService.js | 2 +- src/backend/src/services/ShareService.js | 2 +- src/backend/src/services/UserService.js | 2 +- .../src/services/WebDAV/WebDAVService.js | 6 +-- src/backend/src/services/WispService.js | 2 +- .../abuse-prevention/EdgeRateLimitService.js | 40 +++++++++---------- .../abuse-prevention/IdentificationService.js | 2 +- .../src/services/ai/AIInterfaceService.js | 2 +- .../src/services/ai/ocr/AWSTextractService.js | 4 +- .../ai/sts/ElevenLabsVoiceChangerService.js | 4 +- .../ai/stt/OpenAISpeechToTextService.js | 4 +- .../src/services/ai/tts/AWSPollyService.js | 4 +- .../services/ai/tts/ElevenLabsTTSService.js | 4 +- .../src/services/ai/tts/OpenAITTSService.js | 4 +- .../OpenAIVideoGenerationService.js | 4 +- .../TogetherVideoGenerationService.js | 4 +- src/backend/src/services/auth/ACLService.js | 2 +- .../src/services/auth/AntiCSRFService.js | 2 +- src/backend/src/services/auth/AuthService.js | 2 +- src/backend/src/services/auth/OTPService.js | 2 +- .../src/services/auth/PermissionService.js | 2 +- .../src/services/auth/PreAuthService.js | 2 +- .../src/services/drivers/DriverService.js | 22 +++++----- .../DynamoKVStore/DynamoKVStoreWrapper.js | 2 +- .../web/UserProtectedEndpointsService.js | 2 +- .../src/services/worker/WorkerService.js | 4 +- src/backend/tools/test.mjs | 4 +- 81 files changed, 150 insertions(+), 149 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 442ff39aa..996c17fcf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -65,6 +65,7 @@ export const rules = { }], '@stylistic/array-bracket-spacing': ['error', 'never'], '@stylistic/linebreak-style': ['error', 'unix'], + 'no-useless-computed-key': 'error', 'no-sequences': [ 'error', { allowInParentheses: false, diff --git a/src/backend/src/ExtensionService.js b/src/backend/src/ExtensionService.js index e0bfbffca..197f44543 100644 --- a/src/backend/src/ExtensionService.js +++ b/src/backend/src/ExtensionService.js @@ -153,7 +153,7 @@ class ExtensionService extends BaseService { this.state.extension.emit('preinit'); } - async ['__on_boot.consolidation'] (...a) { + async '__on_boot.consolidation' (...a) { const svc_su = this.services.get('su'); await svc_su.sudo(async () => { await this.state.extension.emit('init', {}, { @@ -161,7 +161,7 @@ class ExtensionService extends BaseService { }); }); } - async ['__on_boot.activation'] (...a) { + async '__on_boot.activation' (...a) { const svc_su = this.services.get('su'); await svc_su.sudo(async () => { await this.state.extension.emit('activate', {}, { @@ -169,7 +169,7 @@ class ExtensionService extends BaseService { }); }); } - async ['__on_boot.ready'] (...a) { + async '__on_boot.ready' (...a) { const svc_su = this.services.get('su'); await svc_su.sudo(async () => { await this.state.extension.emit('ready', {}, { @@ -178,7 +178,7 @@ class ExtensionService extends BaseService { }); } - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { if ( ! this.state ) debugger; for ( const thing of this.state.expressThings_ ) { if ( thing.type === 'endpoint' ) { diff --git a/src/backend/src/Kernel.js b/src/backend/src/Kernel.js index c00701532..fbd9a8b4f 100644 --- a/src/backend/src/Kernel.js +++ b/src/backend/src/Kernel.js @@ -129,7 +129,7 @@ class Kernel extends AdvancedBase { extensionInfo: this.extensionInfo, registry: this.registry, args, - ['runtime-modules']: this.runtimeModuleRegistry, + 'runtime-modules': this.runtimeModuleRegistry, }, 'app'); globalThis.root_context = root_context; @@ -149,7 +149,7 @@ class Kernel extends AdvancedBase { services.registerModule(module_.constructor.name, module_); const mod_context = this._create_mod_context(Context.get(), { name: module_.constructor.name, - ['module']: module_, + 'module': module_, external: false, }); await module_.install(mod_context); @@ -437,7 +437,7 @@ class Kernel extends AdvancedBase { const mod_context = this._create_mod_context(mod_install_root_context, { name: mod_name, - ['module']: mod, + 'module': mod, external: true, mod_path, }); diff --git a/src/backend/src/filesystem/hl_operations/hl_stat.js b/src/backend/src/filesystem/hl_operations/hl_stat.js index 034555ac7..71a350500 100644 --- a/src/backend/src/filesystem/hl_operations/hl_stat.js +++ b/src/backend/src/filesystem/hl_operations/hl_stat.js @@ -24,7 +24,7 @@ const { NodeUIDSelector } = require('../node/selectors'); class HLStat extends HLFilesystemOperation { static MODULES = { - ['mime-types']: require('mime-types'), + 'mime-types': require('mime-types'), }; async _run () { diff --git a/src/backend/src/modules/apps/AppIconService.js b/src/backend/src/modules/apps/AppIconService.js index 3983e3297..4b3e1a24a 100644 --- a/src/backend/src/modules/apps/AppIconService.js +++ b/src/backend/src/modules/apps/AppIconService.js @@ -17,13 +17,13 @@ * along with this program. If not, see . */ -import config from '../../config.js'; import { createRequire } from 'node:module'; +import config from '../../config.js'; +import { APP_ICONS_SUBDOMAIN } from '../../consts/app-icons.js'; import { HLWrite } from '../../filesystem/hl_operations/hl_write.js'; import { LLMkdir } from '../../filesystem/ll_operations/ll_mkdir.js'; import { LLRead } from '../../filesystem/ll_operations/ll_read.js'; import { NodePathSelector } from '../../filesystem/node/selectors.js'; -import { APP_ICONS_SUBDOMAIN } from '../../consts/app-icons.js'; import { get_app, get_user } from '../../helpers.js'; import BaseService from '../../services/BaseService.js'; import { DB_WRITE } from '../../services/database/consts.js'; @@ -69,7 +69,7 @@ export class AppIconService extends BaseService { * endpoints /app-icon/:app_uid and /app-icon/:app_uid/:size * which serve the app icon at the requested size. */ - async ['__on_install.routes'] (_, { app }) { + async '__on_install.routes' (_, { app }) { const handler = async (req, res) => { // Validate parameters let { app_uid: appUid, size } = req.params; @@ -686,7 +686,7 @@ export class AppIconService extends BaseService { * `/system/app_icons` directory if it does not exist, * and then to register the event listener for `app.new-icon`. */ - async ['__on_user.system-user-ready'] () { + async '__on_user.system-user-ready' () { const svcSu = this.services.get('su'); const svcUser = this.services.get('user'); diff --git a/src/backend/src/modules/apps/OldAppNameService.js b/src/backend/src/modules/apps/OldAppNameService.js index ca52acab6..8e00acec6 100644 --- a/src/backend/src/modules/apps/OldAppNameService.js +++ b/src/backend/src/modules/apps/OldAppNameService.js @@ -29,7 +29,7 @@ class OldAppNameService extends BaseService { this.db = this.services.get('database').get(DB_READ, 'old-app-name'); } - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const svc_event = this.services.get('event'); svc_event.on('app.rename', async (_, { app_uid, old_name }) => { this.log.info('GOT EVENT', { app_uid, old_name }); diff --git a/src/backend/src/modules/apps/RecommendedAppsService.js b/src/backend/src/modules/apps/RecommendedAppsService.js index d87ef79e5..7e9b0fe20 100644 --- a/src/backend/src/modules/apps/RecommendedAppsService.js +++ b/src/backend/src/modules/apps/RecommendedAppsService.js @@ -69,7 +69,7 @@ export default class RecommendedAppsService extends BaseService { this.app_names = new Set(RecommendedAppsService.APP_NAMES); } - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { const svc_appIcon = this.services.get('app-icon'); const svc_event = this.services.get('event'); svc_event.on('apps.invalidate', async (_, { app }) => { diff --git a/src/backend/src/modules/broadcast/BroadcastService.js b/src/backend/src/modules/broadcast/BroadcastService.js index e2d3a4539..fb11cfb47 100644 --- a/src/backend/src/modules/broadcast/BroadcastService.js +++ b/src/backend/src/modules/broadcast/BroadcastService.js @@ -98,7 +98,7 @@ class BroadcastService extends BaseService { } } - async ['__on_install.routes'] (_, { app }) { + async '__on_install.routes' (_, { app }) { const svc_web = this.services.get('web-server'); svc_web.allow_undefined_origin('/broadcast/webhook'); @@ -253,7 +253,7 @@ class BroadcastService extends BaseService { } } - async ['__on_install.websockets'] () { + async '__on_install.websockets' () { const svc_event = this.services.get('event'); const svc_webServer = this.services.get('web-server'); diff --git a/src/backend/src/modules/captcha/services/CaptchaService.js b/src/backend/src/modules/captcha/services/CaptchaService.js index 3919f50ce..74dccf40b 100644 --- a/src/backend/src/modules/captcha/services/CaptchaService.js +++ b/src/backend/src/modules/captcha/services/CaptchaService.js @@ -60,7 +60,7 @@ class CaptchaService extends BaseService { this.endpointsRegistered = false; } - async ['__on_install.middlewares.context-aware'] (_, { app }) { + async '__on_install.middlewares.context-aware' (_, { app }) { // Add express middleware app.use(checkCaptcha({ svc_captcha: this })); } diff --git a/src/backend/src/modules/core/AlarmService.js b/src/backend/src/modules/core/AlarmService.js index cc8bb2715..ddbe8ebcb 100644 --- a/src/backend/src/modules/core/AlarmService.js +++ b/src/backend/src/modules/core/AlarmService.js @@ -61,7 +61,7 @@ class AlarmService extends BaseService { * AlarmService registers its commands at the consolidation phase because * the '_init' method of CommandService may not have been called yet. */ - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { this._register_commands(this.services.get('commands')); } diff --git a/src/backend/src/modules/core/ExpectationService.js b/src/backend/src/modules/core/ExpectationService.js index ebe91e524..8506ea454 100644 --- a/src/backend/src/modules/core/ExpectationService.js +++ b/src/backend/src/modules/core/ExpectationService.js @@ -52,7 +52,7 @@ class ExpectationService extends BaseService { * ExpectationService registers its commands at the consolidation phase because * the '_init' method of CommandService may not have been called yet. */ - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { const commands = this.services.get('commands'); commands.registerCommands('expectations', [ { diff --git a/src/backend/src/modules/core/LogService.js b/src/backend/src/modules/core/LogService.js index 365ac0254..5edd2bc7e 100644 --- a/src/backend/src/modules/core/LogService.js +++ b/src/backend/src/modules/core/LogService.js @@ -398,7 +398,7 @@ class LogService extends BaseService { /** * Registers logging commands with the command service. */ - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { const commands = this.services.get('commands'); commands.registerCommands('logs', [ { diff --git a/src/backend/src/modules/core/PagerService.js b/src/backend/src/modules/core/PagerService.js index 1f4fc6aad..648fba2ca 100644 --- a/src/backend/src/modules/core/PagerService.js +++ b/src/backend/src/modules/core/PagerService.js @@ -44,7 +44,7 @@ class PagerService extends BaseService { * PagerService registers its commands at the consolidation phase because * the '_init' method of CommandService may not have been called yet. */ - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { this._register_commands(this.services.get('commands')); } diff --git a/src/backend/src/modules/core/ParameterService.js b/src/backend/src/modules/core/ParameterService.js index 84f11c7d8..ccd4206d8 100644 --- a/src/backend/src/modules/core/ParameterService.js +++ b/src/backend/src/modules/core/ParameterService.js @@ -97,7 +97,7 @@ class ParameterService extends BaseService { * for parameter management. * @private */ - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { this._registerCommands(this.services.get('commands')); } diff --git a/src/backend/src/modules/data-access/AppService.js b/src/backend/src/modules/data-access/AppService.js index 9a5745278..515bb92b6 100644 --- a/src/backend/src/modules/data-access/AppService.js +++ b/src/backend/src/modules/data-access/AppService.js @@ -263,7 +263,7 @@ export default class AppService extends BaseService { static WRITE_ALL_OWNER_PERMISSION = 'system:es:write-all-owners'; static IMPLEMENTS = { - ['crud-q']: { + 'crud-q': { async create ({ object, options }) { return await this.#create({ object, options }); }, diff --git a/src/backend/src/modules/development/LocalTerminalService.js b/src/backend/src/modules/development/LocalTerminalService.js index 8a1f93cf2..e9c246bff 100644 --- a/src/backend/src/modules/development/LocalTerminalService.js +++ b/src/backend/src/modules/development/LocalTerminalService.js @@ -35,7 +35,7 @@ class LocalTerminalService extends BaseService { } get_profiles () { return { - ['api-test']: { + 'api-test': { cwd: path_.join(__dirname, '../../../../../', 'tools/api-tester'), @@ -48,7 +48,7 @@ class LocalTerminalService extends BaseService { }, }; }; - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { const r_group = (() => { const require = this.require; const express = require('express'); diff --git a/src/backend/src/modules/domain/TXTVerifyService.js b/src/backend/src/modules/domain/TXTVerifyService.js index 33cebe08b..e5ff7e2ad 100644 --- a/src/backend/src/modules/domain/TXTVerifyService.js +++ b/src/backend/src/modules/domain/TXTVerifyService.js @@ -3,7 +3,7 @@ const BaseService = require('../../services/BaseService'); const { atimeout } = require('../../util/asyncutil'); class TXTVerifyService extends BaseService { - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { const svc_dns = this.services.get('dns'); const dns = svc_dns.get_client(); diff --git a/src/backend/src/modules/entitystore/EntityStoreInterfaceService.js b/src/backend/src/modules/entitystore/EntityStoreInterfaceService.js index 08236b408..3b0f3ce0b 100644 --- a/src/backend/src/modules/entitystore/EntityStoreInterfaceService.js +++ b/src/backend/src/modules/entitystore/EntityStoreInterfaceService.js @@ -30,7 +30,7 @@ class EntityStoreInterfaceService extends BaseService { * Service class for managing Entity Store interface registrations. * Extends the base service to provide entity storage interface management. */ - async ['__on_driver.register.interfaces'] () { + async '__on_driver.register.interfaces' () { const svc_registry = this.services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); diff --git a/src/backend/src/modules/kvstore/KVStoreInterfaceService.js b/src/backend/src/modules/kvstore/KVStoreInterfaceService.js index cfb4e7f69..a10a8a402 100644 --- a/src/backend/src/modules/kvstore/KVStoreInterfaceService.js +++ b/src/backend/src/modules/kvstore/KVStoreInterfaceService.js @@ -87,7 +87,7 @@ class KVStoreInterfaceService extends BaseService { * Service class for managing KVStore interface registrations. * Extends the base service to provide key-value store interface management. */ - async ['__on_driver.register.interfaces'] () { + async '__on_driver.register.interfaces' () { const svc_registry = this.services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); diff --git a/src/backend/src/modules/puterfs/MountpointService.js b/src/backend/src/modules/puterfs/MountpointService.js index 5250d8daf..56bfdc980 100644 --- a/src/backend/src/modules/puterfs/MountpointService.js +++ b/src/backend/src/modules/puterfs/MountpointService.js @@ -46,7 +46,7 @@ class MountpointService extends BaseService { this.#mounters[name] = mounter; } - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { // Emit event for registering filesystem types const svc_event = this.services.get('event'); const event = {}; diff --git a/src/backend/src/modules/puterfs/SizeService.js b/src/backend/src/modules/puterfs/SizeService.js index f3fbc0725..bf67379e6 100644 --- a/src/backend/src/modules/puterfs/SizeService.js +++ b/src/backend/src/modules/puterfs/SizeService.js @@ -41,7 +41,7 @@ class SizeService extends BaseService { } - ['__on_boot.consolidate'] () { + '__on_boot.consolidate' () { const svc_commands = this.services.get('commands'); svc_commands.registerCommands('size', [ { diff --git a/src/backend/src/modules/selfhosted/DefaultUserService.js b/src/backend/src/modules/selfhosted/DefaultUserService.js index 190c6fcf6..44c6a18a9 100644 --- a/src/backend/src/modules/selfhosted/DefaultUserService.js +++ b/src/backend/src/modules/selfhosted/DefaultUserService.js @@ -71,7 +71,7 @@ class DefaultUserService extends BaseService { async _init () { this._register_commands(this.services.get('commands')); } - async ['__on_ready.webserver'] () { + async '__on_ready.webserver' () { // check if a user named `admin` exists let user = await get_user({ username: USERNAME, cached: false }); if ( ! user ) { @@ -251,7 +251,7 @@ class DefaultUserService extends BaseService { { id: 'reset-password', handler: async (args, ctx) => { - const [ username ] = args; + const [username] = args; const user = await get_user({ username }); const tmp_pwd = await this.force_tmp_password_(user); ctx.log(`New password for ${quot(username)} is: ${tmp_pwd}`); diff --git a/src/backend/src/modules/selfhosted/DevWatcherService.js b/src/backend/src/modules/selfhosted/DevWatcherService.js index 073304340..193c76cbd 100644 --- a/src/backend/src/modules/selfhosted/DevWatcherService.js +++ b/src/backend/src/modules/selfhosted/DevWatcherService.js @@ -67,7 +67,7 @@ class DevWatcherService extends BaseService { // port is set to `auto` - you have no idea how confusing // this was to debug the first time, like Ahhhhhh!! // but hey at least we have this convenient event listener. - async ['__on_ready.webserver'] () { + async '__on_ready.webserver' () { const svc_process = this.services.get('process'); let { root, commands, webpack } = this.args; diff --git a/src/backend/src/modules/selfhosted/ServeSingeFileService.js b/src/backend/src/modules/selfhosted/ServeSingeFileService.js index 5f631e5fe..9723c0a04 100644 --- a/src/backend/src/modules/selfhosted/ServeSingeFileService.js +++ b/src/backend/src/modules/selfhosted/ServeSingeFileService.js @@ -23,7 +23,7 @@ class ServeSingleFileService extends BaseService { this.route = args.route; this.path = args.path; } - async ['__on_install.routes'] () { + async '__on_install.routes' () { const { app } = this.services.get('web-server'); app.get(this.route, (req, res) => { diff --git a/src/backend/src/modules/selfhosted/ServeStaticFilesService.js b/src/backend/src/modules/selfhosted/ServeStaticFilesService.js index baa1520ad..8408bf957 100644 --- a/src/backend/src/modules/selfhosted/ServeStaticFilesService.js +++ b/src/backend/src/modules/selfhosted/ServeStaticFilesService.js @@ -23,7 +23,7 @@ class ServeStaticFilesService extends BaseService { this.directories = args.directories; } - async ['__on_install.routes'] () { + async '__on_install.routes' () { const { app } = this.services.get('web-server'); for ( const { prefix, path } of this.directories ) { diff --git a/src/backend/src/modules/template/TemplateService.js b/src/backend/src/modules/template/TemplateService.js index 1cbd042ba..520f7f72e 100644 --- a/src/backend/src/modules/template/TemplateService.js +++ b/src/backend/src/modules/template/TemplateService.js @@ -45,7 +45,7 @@ class TemplateService extends BaseService { /** * TemplateService listens to this event to provide an example endpoint */ - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { this.log.info('TemplateService get the event for installing endpoint.'); Endpoint({ route: '/example-endpoint', @@ -61,7 +61,7 @@ class TemplateService extends BaseService { /** * TemplateService listens to this event to provide an example event */ - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { // At this stage, all services have been initialized and it is // safe to start emitting events. this.log.info('TemplateService sees consolidation boot phase.'); @@ -81,14 +81,14 @@ class TemplateService extends BaseService { /** * TemplateService listens to this event to show you that it's here */ - ['__on_boot.activation'] () { + '__on_boot.activation' () { this.log.info('TemplateService sees activation boot phase.'); } /** * TemplateService listens to this event to show you that it's here */ - ['__on_start.webserver'] () { + '__on_start.webserver' () { this.log.info("TemplateService sees it's time to start web servers."); } } diff --git a/src/backend/src/modules/test-drivers/TestAssetHostService.js b/src/backend/src/modules/test-drivers/TestAssetHostService.js index 93167de62..f367db232 100644 --- a/src/backend/src/modules/test-drivers/TestAssetHostService.js +++ b/src/backend/src/modules/test-drivers/TestAssetHostService.js @@ -20,7 +20,7 @@ const BaseService = require('../../services/BaseService'); class TestAssetHostService extends BaseService { - async ['__on_install.routes'] () { + async '__on_install.routes' () { const { app } = this.services.get('web-server'); const path_ = require('node:path'); diff --git a/src/backend/src/modules/test-drivers/TestImageService.js b/src/backend/src/modules/test-drivers/TestImageService.js index f646794c1..36034207c 100644 --- a/src/backend/src/modules/test-drivers/TestImageService.js +++ b/src/backend/src/modules/test-drivers/TestImageService.js @@ -31,7 +31,7 @@ const PUBLIC_DOMAIN_IMAGES = [ ]; class TestImageService extends BaseService { - async ['__on_driver.register.interfaces'] () { + async '__on_driver.register.interfaces' () { const svc_registry = this.services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); @@ -68,12 +68,12 @@ class TestImageService extends BaseService { } static IMPLEMENTS = { - ['version']: { + 'version': { get_version () { return 'v1.0.0'; }, }, - ['test-image']: { + 'test-image': { async echo_image ({ source, }) { diff --git a/src/backend/src/modules/web/SocketioService.js b/src/backend/src/modules/web/SocketioService.js index fcd75df83..08669b2a2 100644 --- a/src/backend/src/modules/web/SocketioService.js +++ b/src/backend/src/modules/web/SocketioService.js @@ -31,7 +31,7 @@ class SocketioService extends BaseService { * * @evtparam server The server to attach socket.io to. */ - ['__on_install.socketio'] (_, { server }) { + '__on_install.socketio' (_, { server }) { /** * @type {import('socket.io').Server} */ diff --git a/src/backend/src/modules/web/WebServerService.js b/src/backend/src/modules/web/WebServerService.js index c02d542e5..cf23ee80c 100644 --- a/src/backend/src/modules/web/WebServerService.js +++ b/src/backend/src/modules/web/WebServerService.js @@ -46,7 +46,7 @@ class WebServerService extends BaseService { helmet: require('helmet'), cookieParser: require('cookie-parser'), compression: require('compression'), - ['on-finished']: require('on-finished'), + 'on-finished': require('on-finished'), morgan: require('morgan'), }; @@ -64,7 +64,7 @@ class WebServerService extends BaseService { * @private */ // comment above line 44 in WebServerService.js - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const app = this.app; const services = this.services; await services.emit('install.middlewares.early', { app }); @@ -115,7 +115,7 @@ class WebServerService extends BaseService { * * @returns {Promise} A promise that resolves once the server is started. */ - async ['__on_boot.activation'] () { + async '__on_boot.activation' () { const services = this.services; await services.emit('start.webserver'); await services.emit('ready.webserver'); @@ -130,7 +130,7 @@ class WebServerService extends BaseService { * * @return {Promise} A promise that resolves when the server is up and running. */ - async ['__on_start.webserver'] () { + async '__on_start.webserver' () { // error handling middleware goes last, as per the // expressjs documentation: // https://expressjs.com/en/guide/error-handling.html diff --git a/src/backend/src/om/proptypes/__all__.js b/src/backend/src/om/proptypes/__all__.js index 995212f3a..a48ca7519 100644 --- a/src/backend/src/om/proptypes/__all__.js +++ b/src/backend/src/om/proptypes/__all__.js @@ -282,7 +282,7 @@ module.exports = { return is_valid_uuid4(value); }, }, - ['puter-uuid']: { + 'puter-uuid': { from: 'string', validate (value, { descriptor }) { const prefix = `${descriptor.prefix }-`; @@ -297,7 +297,7 @@ module.exports = { return prefix + uuid; }, }, - ['image-base64']: { + 'image-base64': { from: 'string', is_set (value) { return typeof value === 'string' && value.trim().length > 0; @@ -382,7 +382,7 @@ module.exports = { datetime: { from: 'base', }, - ['puter-node']: { + 'puter-node': { // from: 'base', async sql_reference (value) { if ( value === null ) return null; diff --git a/src/backend/src/routers/hosting/puter-site.js b/src/backend/src/routers/hosting/puter-site.js index 9319bd9ac..57ec2002c 100644 --- a/src/backend/src/routers/hosting/puter-site.js +++ b/src/backend/src/routers/hosting/puter-site.js @@ -642,7 +642,7 @@ class PuterSiteMiddleware extends AdvancedBase { res.redirect(`${config.origin}?${e.querystringize({ ...(req.query['puter.app_instance_id'] ? { - ['error_from_within_iframe']: true, + 'error_from_within_iframe': true, } : {}), })}`); } diff --git a/src/backend/src/services/BootScriptService.js b/src/backend/src/services/BootScriptService.js index 2a2172601..82fc2aca3 100644 --- a/src/backend/src/services/BootScriptService.js +++ b/src/backend/src/services/BootScriptService.js @@ -41,7 +41,7 @@ class BootScriptService extends BaseService { * @function * @returns {Promise} */ - async ['__on_boot.ready'] () { + async '__on_boot.ready' () { const args = Context.get('args'); if ( ! args['boot-script'] ) return; const script_name = args['boot-script']; @@ -66,7 +66,7 @@ class BootScriptService extends BaseService { async run_script (boot_json) { const scope = { runner: 'boot-script', - ['end-puter-process']: ({ args }) => { + 'end-puter-process': ({ args }) => { const svc_shutdown = this.services.get('shutdown'); svc_shutdown.shutdown(args[0]); }, diff --git a/src/backend/src/services/ChatAPIService.js b/src/backend/src/services/ChatAPIService.js index d4784a3b4..8e1079ead 100644 --- a/src/backend/src/services/ChatAPIService.js +++ b/src/backend/src/services/ChatAPIService.js @@ -40,7 +40,7 @@ class ChatAPIService extends BaseService { * @param {Express} options.app Express application instance to install routes on * @returns {Promise} */ - async ['__on_install.routes'] (_, { app }) { + async '__on_install.routes' (_, { app }) { // Create a router for chat API endpoints const router = (() => { const require = this.require; diff --git a/src/backend/src/services/CommandService.js b/src/backend/src/services/CommandService.js index ba98342a3..ee7155809 100644 --- a/src/backend/src/services/CommandService.js +++ b/src/backend/src/services/CommandService.js @@ -99,7 +99,7 @@ class CommandService extends BaseService { })); } - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const svc_event = this.services.get('event'); const svc_command = this; const event = { diff --git a/src/backend/src/services/ContextInitService.js b/src/backend/src/services/ContextInitService.js index 253e3eacf..1f5b441c4 100644 --- a/src/backend/src/services/ContextInitService.js +++ b/src/backend/src/services/ContextInitService.js @@ -97,7 +97,7 @@ class ContextInitService extends BaseService { key, async_factory, }); } - async ['__on_install.middlewares.context-aware'] (_, { app }) { + async '__on_install.middlewares.context-aware' (_, { app }) { this.mw.install(app); await this.services.emit('install.context-initializers'); } diff --git a/src/backend/src/services/EntityStoreService.js b/src/backend/src/services/EntityStoreService.js index d42b64abe..4d28a6898 100644 --- a/src/backend/src/services/EntityStoreService.js +++ b/src/backend/src/services/EntityStoreService.js @@ -61,7 +61,7 @@ class EntityStoreService extends BaseService { } static IMPLEMENTS = { - ['crud-q']: { + 'crud-q': { async create ({ object, options }) { if ( object.hasOwnProperty(this.om.primary_identifier) ) { throw APIError.create('field_not_allowed_for_create', null, { @@ -103,7 +103,7 @@ class EntityStoreService extends BaseService { es_params: options?.params ?? {}, }).arun(async () => { const entities = await this.select(options); - const promises = []; + const promises = []; for ( const entity of entities ) { promises.push(entity.get_client_safe()); } diff --git a/src/backend/src/services/EntriService.js b/src/backend/src/services/EntriService.js index c1bb08943..784157bdf 100644 --- a/src/backend/src/services/EntriService.js +++ b/src/backend/src/services/EntriService.js @@ -45,7 +45,7 @@ class EntriService extends BaseService { parseDomain = (await import('parse-domain')).parseDomain; } - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { Endpoint({ route: '/entri/webhook', methods: ['POST', 'GET'], @@ -94,7 +94,7 @@ class EntriService extends BaseService { } static IMPLEMENTS = { - ['entri']: { + 'entri': { async getConfig ({ domain, userHostedSite }) { const es_subdomain = this.services.get('es:subdomain'); const svc_su = this.services.get('su'); @@ -231,7 +231,7 @@ class EntriService extends BaseService { }, }, }; - async ['__on_driver.register.interfaces'] () { + async '__on_driver.register.interfaces' () { const svc_registry = this.services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); diff --git a/src/backend/src/services/EventService.js b/src/backend/src/services/EventService.js index 3b2879608..e05fdb727 100644 --- a/src/backend/src/services/EventService.js +++ b/src/backend/src/services/EventService.js @@ -59,7 +59,7 @@ class EventService extends BaseService { this.global_listeners_ = []; } - async ['__on_boot.ready'] () { + async '__on_boot.ready' () { this.emit('ready', {}, {}); } diff --git a/src/backend/src/services/FilesystemAPIService.js b/src/backend/src/services/FilesystemAPIService.js index 9658eebed..9f325945c 100644 --- a/src/backend/src/services/FilesystemAPIService.js +++ b/src/backend/src/services/FilesystemAPIService.js @@ -38,7 +38,7 @@ class FilesystemAPIService extends BaseService { * @function __on_install.routes * @returns {Promise} A promise that resolves when the routes are set up. */ - async ['__on_install.routes'] () { + async '__on_install.routes' () { const { app } = this.services.get('web-server'); // batch diff --git a/src/backend/src/services/HelloWorldService.js b/src/backend/src/services/HelloWorldService.js index c8eb6ebae..961bac461 100644 --- a/src/backend/src/services/HelloWorldService.js +++ b/src/backend/src/services/HelloWorldService.js @@ -28,7 +28,7 @@ const BaseService = require('./BaseService'); */ class HelloWorldService extends BaseService { static IMPLEMENTS = { - ['version']: { + 'version': { /** * Returns the current version of the service. * @@ -38,7 +38,7 @@ class HelloWorldService extends BaseService { return 'v1.0.0'; }, }, - ['hello-world']: { + 'hello-world': { /** * Greets the user with a customizable message. * diff --git a/src/backend/src/services/KernelInfoService.js b/src/backend/src/services/KernelInfoService.js index 71ed00dfb..08c2a7d87 100644 --- a/src/backend/src/services/KernelInfoService.js +++ b/src/backend/src/services/KernelInfoService.js @@ -46,7 +46,7 @@ class KernelInfoService extends BaseService { * @param {Express} param1.app Express application instance * @private */ - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { const router = (() => { const require = this.require; const express = require('express'); diff --git a/src/backend/src/services/LocalDiskStorageService.js b/src/backend/src/services/LocalDiskStorageService.js index 1b7d9f954..f55fa6485 100644 --- a/src/backend/src/services/LocalDiskStorageService.js +++ b/src/backend/src/services/LocalDiskStorageService.js @@ -43,7 +43,7 @@ class LocalDiskStorageService extends BaseService { * * @returns {Promise} A promise that resolves when the context is initialized. */ - async ['__on_install.context-initializers'] () { + async '__on_install.context-initializers' () { const svc_contextInit = this.services.get('context-init'); const storage = new LocalDiskStorageStrategy({ services: this.services }); svc_contextInit.register_value('storage', storage); diff --git a/src/backend/src/services/MakeProdDebuggingLessAwfulService.js b/src/backend/src/services/MakeProdDebuggingLessAwfulService.js index b97b11448..13a35aa8e 100644 --- a/src/backend/src/services/MakeProdDebuggingLessAwfulService.js +++ b/src/backend/src/services/MakeProdDebuggingLessAwfulService.js @@ -110,7 +110,7 @@ class MakeProdDebuggingLessAwfulService extends BaseService { * @param {Express} options.app Express application instance * @returns {Promise} */ - async ['__on_install.middlewares.context-aware'] (_, { app }) { + async '__on_install.middlewares.context-aware' (_, { app }) { // Add express middleware this.mw.install(app); } diff --git a/src/backend/src/services/NotificationService.js b/src/backend/src/services/NotificationService.js index 1afceacbc..e672e8c47 100644 --- a/src/backend/src/services/NotificationService.js +++ b/src/backend/src/services/NotificationService.js @@ -114,7 +114,7 @@ class NotificationService extends BaseService { this.notifs_pending_write = {}; } - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { const require = this.require; const express = require('express'); const router = express.Router(); diff --git a/src/backend/src/services/PermissionAPIService.js b/src/backend/src/services/PermissionAPIService.js index 1e68d7b01..94eb6d7f1 100644 --- a/src/backend/src/services/PermissionAPIService.js +++ b/src/backend/src/services/PermissionAPIService.js @@ -41,7 +41,7 @@ class PermissionAPIService extends BaseService { * @param {Express} options.app Express application instance to install routes on * @returns {Promise} */ - async ['__on_install.routes'] (_, { app }) { + async '__on_install.routes' (_, { app }) { app.use(require('../routers/auth/get-user-app-token')); app.use(require('../routers/auth/grant-user-app')); app.use(require('../routers/auth/revoke-user-app')); diff --git a/src/backend/src/services/PuterAPIService.js b/src/backend/src/services/PuterAPIService.js index e7e3f4353..6600dc8e0 100644 --- a/src/backend/src/services/PuterAPIService.js +++ b/src/backend/src/services/PuterAPIService.js @@ -38,7 +38,7 @@ class PuterAPIService extends BaseService { * This method registers various API endpoints with the web server. * It does not return a value as it configures the server directly. */ - async ['__on_install.routes'] () { + async '__on_install.routes' () { const svc_web = this.services.get('web-server'); const { app } = svc_web; svc_web.allow_undefined_origin('/healthcheck'); diff --git a/src/backend/src/services/PuterHomepageService.js b/src/backend/src/services/PuterHomepageService.js index 23b7df571..4c61a32f6 100644 --- a/src/backend/src/services/PuterHomepageService.js +++ b/src/backend/src/services/PuterHomepageService.js @@ -59,7 +59,7 @@ export class PuterHomepageService extends BaseService { this.gui_params[key] = val; } - async ['__on_install.routes'] (_, { app }) { + async '__on_install.routes' (_, { app }) { Endpoint({ route: '/whoarewe', methods: ['GET'], @@ -327,8 +327,8 @@ export class PuterHomepageService extends BaseService { ${((!bundled && manifest?.css_paths) - ? manifest.css_paths.map(path => `\n`) - : []).join('') + ? manifest.css_paths.map(path => `\n`) + : []).join('') } diff --git a/src/backend/src/services/PuterVersionService.js b/src/backend/src/services/PuterVersionService.js index 506cb4c44..156f1d880 100644 --- a/src/backend/src/services/PuterVersionService.js +++ b/src/backend/src/services/PuterVersionService.js @@ -43,7 +43,7 @@ class PuterVersionService extends BaseService { * @async * @returns {Promise} Resolves when the routes are successfully registered. */ - async ['__on_install.routes'] () { + async '__on_install.routes' () { const { app } = this.services.get('web-server'); app.use(require('../routers/version')); } diff --git a/src/backend/src/services/RefreshAssociationsService.js b/src/backend/src/services/RefreshAssociationsService.js index 291a1f328..82c34c973 100644 --- a/src/backend/src/services/RefreshAssociationsService.js +++ b/src/backend/src/services/RefreshAssociationsService.js @@ -36,7 +36,7 @@ class RefreshAssociationsService extends BaseService { * @async * @returns {Promise} - A promise that resolves when the cache refresh process is complete. */ - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const { refresh_associations_cache } = require('../helpers'); /** diff --git a/src/backend/src/services/RegistryService.js b/src/backend/src/services/RegistryService.js index 6c8d5661a..20d5b90fb 100644 --- a/src/backend/src/services/RegistryService.js +++ b/src/backend/src/services/RegistryService.js @@ -101,7 +101,7 @@ class RegistryService extends BaseService { * * @private */ - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const services = this.services; await services.emit('registry.collections'); await services.emit('registry.entries'); diff --git a/src/backend/src/services/RequestMeasureService.js b/src/backend/src/services/RequestMeasureService.js index 5d3dfcdac..9d8c1eb02 100644 --- a/src/backend/src/services/RequestMeasureService.js +++ b/src/backend/src/services/RequestMeasureService.js @@ -1,7 +1,7 @@ const BaseService = require('./BaseService'); class RequestMeasureService extends BaseService { - async ['__on_install.middlewares.context-aware'] (_, { app }) { + async '__on_install.middlewares.context-aware' (_, { app }) { const svc_event = this.services.get('event'); app.use(async (req, res, next) => { next(); diff --git a/src/backend/src/services/SNSService.js b/src/backend/src/services/SNSService.js index 1fd87b692..6e774f2aa 100644 --- a/src/backend/src/services/SNSService.js +++ b/src/backend/src/services/SNSService.js @@ -63,7 +63,7 @@ class SNSService extends BaseService { svc_web.allow_undefined_origin('/sns', '/sns/'); } - async ['__on_install.routes'] (_, { app }) { + async '__on_install.routes' (_, { app }) { Endpoint({ route: '/sns', methods: ['POST'], diff --git a/src/backend/src/services/SUService.js b/src/backend/src/services/SUService.js index 3dcf09d18..fc6369f95 100644 --- a/src/backend/src/services/SUService.js +++ b/src/backend/src/services/SUService.js @@ -53,7 +53,7 @@ export class SUService extends BaseService { * @returns {Promise} A promise that resolves when both the * system user and actor have been set. */ - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const sys_user = await this.services.get('get-user').get_user({ username: 'system' }); this.sys_user_.resolve(sys_user); const sys_actor = new Actor({ diff --git a/src/backend/src/services/ServeGUIService.js b/src/backend/src/services/ServeGUIService.js index 4255d6d1a..9d5467a5b 100644 --- a/src/backend/src/services/ServeGUIService.js +++ b/src/backend/src/services/ServeGUIService.js @@ -35,7 +35,7 @@ class ServeGUIService extends BaseService { * @async * @returns {Promise} Resolves when routing is successfully set up. */ - async ['__on_install.routes-gui'] () { + async '__on_install.routes-gui' () { const { app } = this.services.get('web-server'); // is this a puter.site domain? diff --git a/src/backend/src/services/ShareService.js b/src/backend/src/services/ShareService.js index e6c6d9414..a01722a99 100644 --- a/src/backend/src/services/ShareService.js +++ b/src/backend/src/services/ShareService.js @@ -86,7 +86,7 @@ class ShareService extends BaseService { }); } - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { this.install_sharelink_endpoints({ app }); this.install_share_endpoint({ app }); } diff --git a/src/backend/src/services/UserService.js b/src/backend/src/services/UserService.js index c9b4de12d..ad2c8b89b 100644 --- a/src/backend/src/services/UserService.js +++ b/src/backend/src/services/UserService.js @@ -35,7 +35,7 @@ class UserService extends BaseService { this.dir_system = null; } - async ['__on_filesystem.ready'] () { + async '__on_filesystem.ready' () { const svc_fs = this.services.get('filesystem'); // Ensure system user has a home directory const dir_system = await svc_fs.node(new NodeChildSelector(new RootNodeSelector(), diff --git a/src/backend/src/services/WebDAV/WebDAVService.js b/src/backend/src/services/WebDAV/WebDAVService.js index 41a53100a..c081d7cdb 100644 --- a/src/backend/src/services/WebDAV/WebDAVService.js +++ b/src/backend/src/services/WebDAV/WebDAVService.js @@ -157,7 +157,7 @@ class WebDAVService extends BaseService { const base64Credentials = authHeader.split(' ')[1]; const credentials = Buffer.from(base64Credentials, 'base64').toString( 'ascii'); - let [ username, ...password ] = credentials.split(':'); + let [username, ...password] = credentials.split(':'); password = password.join(':'); // Call user's authentication function @@ -209,7 +209,7 @@ class WebDAVService extends BaseService { methodHandler(req, res, filePath, fileNode, headerLockToken); } - ['__on_install.routes'] ( _, { app } ) { + '__on_install.routes' ( _, { app } ) { COOKIE_NAME = this.global_config.cookie_name; const r_webdav = (() => { @@ -238,7 +238,7 @@ class WebDAVService extends BaseService { 'UNLOCK', 'OPTIONS', ], - mw: [ configurable_auth({ optional: true }) ], + mw: [configurable_auth({ optional: true })], /** * * @param {import("express").Request} req diff --git a/src/backend/src/services/WispService.js b/src/backend/src/services/WispService.js index 06bc53fa8..8939ef689 100644 --- a/src/backend/src/services/WispService.js +++ b/src/backend/src/services/WispService.js @@ -22,7 +22,7 @@ const { Endpoint } = require('../util/expressutil'); const BaseService = require('./BaseService'); class WispService extends BaseService { - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { const r_wisp = (() => { const require = this.require; const express = require('express'); diff --git a/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js b/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js index 4f7f38c5e..eae7e547f 100644 --- a/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js +++ b/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js @@ -44,83 +44,83 @@ class EdgeRateLimitService extends BaseService { */ _construct () { this.scopes = { - ['login']: { + 'login': { limit: 10, window: 15 * MINUTE, }, - ['signup']: { + 'signup': { limit: 10, window: 15 * MINUTE, }, - ['contact-us']: { + 'contact-us': { limit: 10, window: 15 * MINUTE, }, - ['share']: { + 'share': { limit: 30, window: 1 * MINUTE, }, - ['send-confirm-email']: { + 'send-confirm-email': { limit: 10, window: HOUR, }, - ['confirm-email']: { + 'confirm-email': { limit: 10, window: HOUR, }, - ['send-pass-recovery-email']: { + 'send-pass-recovery-email': { limit: 10, window: HOUR, }, - ['verify-pass-recovery-token']: { + 'verify-pass-recovery-token': { limit: 10, window: 15 * MINUTE, }, - ['set-pass-using-token']: { + 'set-pass-using-token': { limit: 10, window: HOUR, }, - ['save-account']: { + 'save-account': { limit: 10, window: HOUR, }, - ['change-email-start']: { + 'change-email-start': { limit: 10, window: HOUR, }, - ['change-email-confirm']: { + 'change-email-confirm': { limit: 10, window: HOUR, }, - ['passwd']: { + 'passwd': { limit: 10, window: HOUR, }, - ['/user-protected/change-password']: { + '/user-protected/change-password': { limit: 10, window: HOUR, }, - ['/user-protected/change-email']: { + '/user-protected/change-email': { limit: 10, window: HOUR, }, - ['/user-protected/change-username']: { + '/user-protected/change-username': { limit: 10, window: HOUR, }, - ['/user-protected/disable-2fa']: { + '/user-protected/disable-2fa': { limit: 10, window: HOUR, }, - ['login-otp']: { + 'login-otp': { limit: 15, window: 30 * MINUTE, }, - ['login-recovery']: { + 'login-recovery': { limit: 10, window: HOUR, }, - ['enable-2fa']: { + 'enable-2fa': { limit: 10, window: HOUR, }, diff --git a/src/backend/src/services/abuse-prevention/IdentificationService.js b/src/backend/src/services/abuse-prevention/IdentificationService.js index 50f5ab9a5..689feea8a 100644 --- a/src/backend/src/services/abuse-prevention/IdentificationService.js +++ b/src/backend/src/services/abuse-prevention/IdentificationService.js @@ -193,7 +193,7 @@ class IdentificationService extends BaseService { /** * We need to listen to this event to install a context-aware middleware */ - async ['__on_install.middlewares.context-aware'] (_, { app }) { + async '__on_install.middlewares.context-aware' (_, { app }) { this.mw.install(app); } } diff --git a/src/backend/src/services/ai/AIInterfaceService.js b/src/backend/src/services/ai/AIInterfaceService.js index 9205ad2b5..5e4b32820 100644 --- a/src/backend/src/services/ai/AIInterfaceService.js +++ b/src/backend/src/services/ai/AIInterfaceService.js @@ -32,7 +32,7 @@ class AIInterfaceService extends BaseService { * Extends the base service to provide AI-related interface management. * Handles registration of OCR, chat completion, image generation, and TTS interfaces. */ - async ['__on_driver.register.interfaces'] () { + async '__on_driver.register.interfaces' () { const svc_registry = this.services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); diff --git a/src/backend/src/services/ai/ocr/AWSTextractService.js b/src/backend/src/services/ai/ocr/AWSTextractService.js index a876b7425..0e6e6ab1b 100644 --- a/src/backend/src/services/ai/ocr/AWSTextractService.js +++ b/src/backend/src/services/ai/ocr/AWSTextractService.js @@ -45,12 +45,12 @@ class AWSTextractService extends BaseService { } static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-ocr' && method_name === 'recognize'; }, }, - ['puter-ocr']: { + 'puter-ocr': { /** * Performs OCR recognition on a document using AWS Textract * @param {Object} params - Recognition parameters diff --git a/src/backend/src/services/ai/sts/ElevenLabsVoiceChangerService.js b/src/backend/src/services/ai/sts/ElevenLabsVoiceChangerService.js index 78e2752fb..9756a5db1 100644 --- a/src/backend/src/services/ai/sts/ElevenLabsVoiceChangerService.js +++ b/src/backend/src/services/ai/sts/ElevenLabsVoiceChangerService.js @@ -46,12 +46,12 @@ class ElevenLabsVoiceChangerService extends BaseService { }; static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-speech2speech' && method_name === 'convert'; }, }, - ['puter-speech2speech']: { + 'puter-speech2speech': { async convert (params) { return this.convert(params); }, diff --git a/src/backend/src/services/ai/stt/OpenAISpeechToTextService.js b/src/backend/src/services/ai/stt/OpenAISpeechToTextService.js index 5aa8418e5..6154ef462 100644 --- a/src/backend/src/services/ai/stt/OpenAISpeechToTextService.js +++ b/src/backend/src/services/ai/stt/OpenAISpeechToTextService.js @@ -99,13 +99,13 @@ class OpenAISpeechToTextService extends BaseService { } static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-speech2txt' && (method_name === 'transcribe' || method_name === 'translate'); }, }, - ['puter-speech2txt']: { + 'puter-speech2txt': { async list_models () { return this.listModels(); }, diff --git a/src/backend/src/services/ai/tts/AWSPollyService.js b/src/backend/src/services/ai/tts/AWSPollyService.js index 7d270dfda..a2cd3d17a 100644 --- a/src/backend/src/services/ai/tts/AWSPollyService.js +++ b/src/backend/src/services/ai/tts/AWSPollyService.js @@ -60,12 +60,12 @@ class AWSPollyService extends BaseService { } static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-tts' && method_name === 'synthesize'; }, }, - ['puter-tts']: { + 'puter-tts': { /** * Implements the driver interface methods for text-to-speech functionality * Contains methods for listing available voices and synthesizing speech diff --git a/src/backend/src/services/ai/tts/ElevenLabsTTSService.js b/src/backend/src/services/ai/tts/ElevenLabsTTSService.js index 0144d8aaa..c4e4c860c 100644 --- a/src/backend/src/services/ai/tts/ElevenLabsTTSService.js +++ b/src/backend/src/services/ai/tts/ElevenLabsTTSService.js @@ -47,12 +47,12 @@ class ElevenLabsTTSService extends BaseService { } static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-tts' && method_name === 'synthesize'; }, }, - ['puter-tts']: { + 'puter-tts': { async list_voices () { return this.listVoices(); }, diff --git a/src/backend/src/services/ai/tts/OpenAITTSService.js b/src/backend/src/services/ai/tts/OpenAITTSService.js index d93592d3a..fc94b2c78 100644 --- a/src/backend/src/services/ai/tts/OpenAITTSService.js +++ b/src/backend/src/services/ai/tts/OpenAITTSService.js @@ -106,12 +106,12 @@ class OpenAITTSService extends BaseService { } static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-tts' && method_name === 'synthesize'; }, }, - ['puter-tts']: { + 'puter-tts': { async list_voices ({ provider } = {}) { if ( provider && provider !== 'openai' ) { return []; diff --git a/src/backend/src/services/ai/video/OpenAIVideoGenerationService/OpenAIVideoGenerationService.js b/src/backend/src/services/ai/video/OpenAIVideoGenerationService/OpenAIVideoGenerationService.js index 7ba4b80e7..7318ab5c7 100644 --- a/src/backend/src/services/ai/video/OpenAIVideoGenerationService/OpenAIVideoGenerationService.js +++ b/src/backend/src/services/ai/video/OpenAIVideoGenerationService/OpenAIVideoGenerationService.js @@ -82,13 +82,13 @@ class OpenAIVideoGenerationService extends BaseService { } static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-video-generation' && method_name === 'generate'; }, }, - ['puter-video-generation']: { + 'puter-video-generation': { async generate (params) { return await this.generateVideo(params); }, diff --git a/src/backend/src/services/ai/video/TogetherVideoGenerationService/TogetherVideoGenerationService.js b/src/backend/src/services/ai/video/TogetherVideoGenerationService/TogetherVideoGenerationService.js index 87b8a38d9..a31b7ec56 100644 --- a/src/backend/src/services/ai/video/TogetherVideoGenerationService/TogetherVideoGenerationService.js +++ b/src/backend/src/services/ai/video/TogetherVideoGenerationService/TogetherVideoGenerationService.js @@ -53,13 +53,13 @@ class TogetherVideoGenerationService extends BaseService { } static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-video-generation' && method_name === 'generate'; }, }, - ['puter-video-generation']: { + 'puter-video-generation': { async generate (params) { return await this.generateVideo(params); }, diff --git a/src/backend/src/services/auth/ACLService.js b/src/backend/src/services/auth/ACLService.js index 3199cbbb4..583c3b932 100644 --- a/src/backend/src/services/auth/ACLService.js +++ b/src/backend/src/services/auth/ACLService.js @@ -101,7 +101,7 @@ class ACLService extends BaseService { * @returns {Promise} True if actor has permission, false otherwise * @private */ - async ['__on_install.routes'] (_, { app }) { + async '__on_install.routes' (_, { app }) { /** * Handles route installation for ACL service endpoints. * Sets up routes for user-to-user permission management including: diff --git a/src/backend/src/services/auth/AntiCSRFService.js b/src/backend/src/services/auth/AntiCSRFService.js index 3343e70c7..e8b0fdbe7 100644 --- a/src/backend/src/services/auth/AntiCSRFService.js +++ b/src/backend/src/services/auth/AntiCSRFService.js @@ -43,7 +43,7 @@ class AntiCSRFService extends BaseService { * * @returns {void} */ - ['__on_install.routes'] () { + '__on_install.routes' () { const { app } = this.services.get('web-server'); app.use(eggspress('/get-anticsrf-token', { diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index 68eab4518..9b743c4e4 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -685,7 +685,7 @@ class AuthService extends BaseService { * Registers GET /get-gui-token. Must be called from the GUI origin (no api. subdomain) * so the HTTP-only session cookie is sent. Returns the GUI token for use in Authorization headers. */ - ['__on_install.routes'] () { + '__on_install.routes' () { const { app } = this.services.get('web-server'); const config = require('../../config'); const { subdomain } = require('../../helpers'); diff --git a/src/backend/src/services/auth/OTPService.js b/src/backend/src/services/auth/OTPService.js index aa7eec9f8..0a167fbf4 100644 --- a/src/backend/src/services/auth/OTPService.js +++ b/src/backend/src/services/auth/OTPService.js @@ -27,7 +27,7 @@ class OTPService extends BaseService { static MODULES = { otpauth: require('otpauth'), crypto: require('crypto'), - ['hi-base32']: require('hi-base32'), + 'hi-base32': require('hi-base32'), }; create_secret (label) { diff --git a/src/backend/src/services/auth/PermissionService.js b/src/backend/src/services/auth/PermissionService.js index 75b7f6e2d..45f5140ab 100644 --- a/src/backend/src/services/auth/PermissionService.js +++ b/src/backend/src/services/auth/PermissionService.js @@ -72,7 +72,7 @@ class PermissionService extends BaseService { this.dbAvgTimes = { count: 0, avg: 0, max: 0 }; } - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const svc_event = this.services.get('event'); // Event to allow extensions to add permissions { diff --git a/src/backend/src/services/auth/PreAuthService.js b/src/backend/src/services/auth/PreAuthService.js index 4b1aa2787..c7c1c9e18 100644 --- a/src/backend/src/services/auth/PreAuthService.js +++ b/src/backend/src/services/auth/PreAuthService.js @@ -2,7 +2,7 @@ const configurable_auth = require('../../middleware/configurable_auth'); const BaseService = require('../BaseService'); class PreAuthService extends BaseService { - async ['__on_install.middlewares.early'] (_, { app }) { + async '__on_install.middlewares.early' (_, { app }) { app.use(configurable_auth({ optional: true })); } } diff --git a/src/backend/src/services/drivers/DriverService.js b/src/backend/src/services/drivers/DriverService.js index 1f9bfe5f1..a39c0b821 100644 --- a/src/backend/src/services/drivers/DriverService.js +++ b/src/backend/src/services/drivers/DriverService.js @@ -130,7 +130,7 @@ class DriverService extends BaseService { }); } - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const svc_registry = this.services.get('registry'); const svc_event = this.services.get('event'); @@ -159,7 +159,7 @@ class DriverService extends BaseService { * This method is responsible for registering collections in the service registry. * It registers 'interfaces', 'drivers', and 'types' collections. */ - async ['__on_registry.collections'] () { + async '__on_registry.collections' () { const svc_registry = this.services.get('registry'); svc_registry.register_collection('interfaces'); svc_registry.register_collection('drivers'); @@ -170,7 +170,7 @@ class DriverService extends BaseService { * It registers 'interfaces', 'drivers', and 'types' collections. * It also populates the 'interfaces' collection with default interfaces and registers the collections with the driver service registry. */ - async ['__on_registry.entries'] () { + async '__on_registry.entries' () { const services = this.services; const svc_registry = services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); @@ -193,7 +193,7 @@ class DriverService extends BaseService { // This allows DriverService to be a driver called "driver". // The driver drivers allows checking metered usage for drivers, // and in the future may provide other driver-related functions. - async ['__on_driver.register.interfaces'] () { + async '__on_driver.register.interfaces' () { const svc_registry = this.services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); @@ -283,13 +283,13 @@ class DriverService extends BaseService { // parameter. To support outdated clients we use this hard-coded // table to map interfaces to default drivers. const iface_to_driver = { - ['puter-ocr']: 'aws-textract', - ['puter-tts']: 'aws-polly', - ['puter-speech2speech']: 'elevenlabs-voice-changer', - ['puter-speech2txt']: 'openai-speech2txt', - ['puter-chat-completion']: 'openai-completion', - ['puter-image-generation']: 'openai-image-generation', - ['puter-video-generation']: 'openai-video-generation', + 'puter-ocr': 'aws-textract', + 'puter-tts': 'aws-polly', + 'puter-speech2speech': 'elevenlabs-voice-changer', + 'puter-speech2txt': 'openai-speech2txt', + 'puter-chat-completion': 'openai-completion', + 'puter-image-generation': 'openai-image-generation', + 'puter-video-generation': 'openai-video-generation', 'puter-apps': 'es:app', 'puter-subdomains': 'es:subdomain', 'puter-notifications': 'es:notification', diff --git a/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStoreWrapper.js b/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStoreWrapper.js index 6af6e92ee..46d6efaeb 100644 --- a/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStoreWrapper.js +++ b/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStoreWrapper.js @@ -41,7 +41,7 @@ class DynamoKVStoreServiceWrapper extends BaseService { }); } static IMPLEMENTS = { - ['puter-kvstore']: Object.getOwnPropertyNames(DynamoKVStore.prototype) + 'puter-kvstore': Object.getOwnPropertyNames(DynamoKVStore.prototype) .filter(n => n !== 'constructor') .reduce((acc, fn) => ({ ...acc, diff --git a/src/backend/src/services/web/UserProtectedEndpointsService.js b/src/backend/src/services/web/UserProtectedEndpointsService.js index 98365ee01..94481c739 100644 --- a/src/backend/src/services/web/UserProtectedEndpointsService.js +++ b/src/backend/src/services/web/UserProtectedEndpointsService.js @@ -63,7 +63,7 @@ class UserProtectedEndpointsService extends BaseService { * @instance * @method __on_install.routes */ - ['__on_install.routes'] () { + '__on_install.routes' () { const router = (() => { const require = this.require; const express = require('express'); diff --git a/src/backend/src/services/worker/WorkerService.js b/src/backend/src/services/worker/WorkerService.js index c68d28d80..abbb2b6ec 100644 --- a/src/backend/src/services/worker/WorkerService.js +++ b/src/backend/src/services/worker/WorkerService.js @@ -183,7 +183,7 @@ class WorkerService extends BaseService { }); } static IMPLEMENTS = { - ['workers']: { + 'workers': { /** * * @param {{filePath: string, workerName: string, authorization: string}} param0 @@ -333,7 +333,7 @@ class WorkerService extends BaseService { }, }, }; - async ['__on_driver.register.interfaces'] () { + async '__on_driver.register.interfaces' () { const svc_registry = this.services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); diff --git a/src/backend/tools/test.mjs b/src/backend/tools/test.mjs index bf8d1b482..4ab017945 100644 --- a/src/backend/tools/test.mjs +++ b/src/backend/tools/test.mjs @@ -107,7 +107,7 @@ export class TestKernel extends AdvancedBase { const root_context = Context.create({ services, useapi: this.useapi, - ['runtime-modules']: this.runtimeModuleRegistry, + 'runtime-modules': this.runtimeModuleRegistry, args: {}, }, 'app'); this.root_context = root_context; @@ -135,7 +135,7 @@ export class TestKernel extends AdvancedBase { const mod_context = this._create_mod_context(mod_install_root_context, { name: module.constructor.name, - ['module']: module, + 'module': module, external: false, }); await this.root_context.arun(async () => { From 4c518830ea2c2370e84a9102f1670ca163f80620 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:56:44 -0500 Subject: [PATCH 31/39] clean(oidc): remove "ghost files" --- .../repositories/DBKVStore/DBKVStore.js | 283 -------- .../src/services/repositories/DDBClient.js | 196 ------ .../services/repositories/DDBClientWrapper.js | 18 - .../DynamoKVStore/DynamoKVStore.js | 647 ------------------ .../DynamoKVStore/DynamoKVStoreWrapper.js | 55 -- .../DynamoKVStore/tableDefinition.js | 24 - 6 files changed, 1223 deletions(-) delete mode 100644 src/backend/src/services/repositories/DBKVStore/DBKVStore.js delete mode 100644 src/backend/src/services/repositories/DDBClient.js delete mode 100644 src/backend/src/services/repositories/DDBClientWrapper.js delete mode 100644 src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.js delete mode 100644 src/backend/src/services/repositories/DynamoKVStore/DynamoKVStoreWrapper.js delete mode 100644 src/backend/src/services/repositories/DynamoKVStore/tableDefinition.js diff --git a/src/backend/src/services/repositories/DBKVStore/DBKVStore.js b/src/backend/src/services/repositories/DBKVStore/DBKVStore.js deleted file mode 100644 index 442dc3c5f..000000000 --- a/src/backend/src/services/repositories/DBKVStore/DBKVStore.js +++ /dev/null @@ -1,283 +0,0 @@ -import murmurhash from 'murmurhash'; -import APIError from '../../../api/APIError.js'; -import { Context } from '../../../util/context.js'; -const GLOBAL_APP_KEY = 'global'; -export class DBKVStore { - #db; - #meteringService; - #global_config = {}; - constructor ({ sqlClient, meteringService, globalConfig }) { - this.#db = sqlClient; - this.#meteringService = meteringService; - this.#global_config = globalConfig; - } - async get ({ key }) { - const actor = Context.get('actor'); - const app = actor.type?.app ?? undefined; - const user = actor.type?.user ?? undefined; - if ( ! user ) { - throw new Error('User not found'); - } - const deleteExpired = async (rows) => { - const query = `DELETE FROM kv WHERE user_id=? AND app=? AND kkey_hash IN (${rows.map(() => '?').join(',')})`; - const params = [user.id, app?.uid ?? GLOBAL_APP_KEY, ...rows.map((r) => r.kkey_hash)]; - return await this.#db.write(query, params); - }; - if ( Array.isArray(key) ) { - const keys = key; - const key_hashes = keys.map((key) => murmurhash.v3(key)); - const placeholders = key_hashes.map(() => '?').join(','); - const params = app - ? [user.id, app.uid, ...key_hashes] - : [user.id, ...key_hashes]; - const rows = app - ? await this.#db.read(`SELECT kkey, value, expireAt FROM kv WHERE user_id=? AND app=? AND kkey_hash IN (${placeholders})`, params) - : await this.#db.read(`SELECT kkey, value, expireAt FROM kv WHERE user_id=? AND (app IS NULL OR app = '${GLOBAL_APP_KEY}') AND kkey_hash IN (${placeholders})`, params); - const kvPairs = {}; - rows.forEach((row) => { - row.value = this.#db.case({ - mysql: () => row.value, - otherwise: () => JSON.parse(row.value ?? 'null'), - })(); - kvPairs[row.kkey] = row.value; - }); - const expiredKeys = []; - rows.forEach((row) => { - if ( row?.expireAt && row.expireAt < Date.now() / 1000 ) { - expiredKeys.push(row); - kvPairs[row.kkey] = null; - } - else { - kvPairs[row.kkey] = row.value ?? null; - } - }); - if ( expiredKeys.length ) { - deleteExpired(expiredKeys); - } - return keys.map((key) => Object.prototype.hasOwnProperty.call(kvPairs, key) ? kvPairs[key] : null); - } - const key_hash = murmurhash.v3(key); - const kv = app - ? await this.#db.read('SELECT * FROM kv WHERE user_id=? AND app=? AND kkey_hash=? LIMIT 1', [user.id, app.uid, key_hash]) - : await this.#db.read(`SELECT * FROM kv WHERE user_id=? AND (app IS NULL OR app = '${GLOBAL_APP_KEY}') AND kkey_hash=? LIMIT 1`, [user.id, key_hash]); - if ( kv[0] ) { - kv[0].value = this.#db.case({ - mysql: () => kv[0].value, - otherwise: () => JSON.parse(kv[0].value ?? 'null'), - })(); - } - if ( kv[0]?.expireAt && kv[0].expireAt < Date.now() / 1000 ) { - deleteExpired([kv[0]]); - return null; - } - await this.#meteringService.incrementUsage(actor, 'kv:read', Array.isArray(key) ? key.length : 1); - return kv[0]?.value ?? null; - } - async set ({ key, value, expireAt }) { - const actor = Context.get('actor'); - const config = this.#global_config; - key = String(key); - if ( Buffer.byteLength(key, 'utf8') > config.kv_max_key_size ) { - throw new Error(`key is too large. Max size is ${config.kv_max_key_size}.`); - } - if ( value !== null && - Buffer.byteLength(JSON.stringify(value), 'utf8') > config.kv_max_value_size ) { - throw new Error(`value is too large. Max size is ${config.kv_max_value_size}.`); - } - const app = actor.type?.app ?? undefined; - const user = actor.type?.user ?? undefined; - if ( ! user ) { - throw new Error('User not found'); - } - const key_hash = murmurhash.v3(key); - try { - await this.#db.write(`INSERT INTO kv (user_id, app, kkey_hash, kkey, value, expireAt) - VALUES (?, ?, ?, ?, ?, ?) ${this.#db.case({ - mysql: 'ON DUPLICATE KEY UPDATE value = ?', - sqlite: 'ON CONFLICT(user_id, app, kkey_hash) DO UPDATE SET value = excluded.value', - })}`, [ - user.id, - app?.uid ?? GLOBAL_APP_KEY, - key_hash, - key, - JSON.stringify(value), - expireAt ?? undefined, - ...this.#db.case({ mysql: [value], otherwise: [] }), - ]); - } - catch (e) { - console.error(e); - } - await this.#meteringService.incrementUsage(actor, 'kv:write', 1); - return true; - } - async del ({ key }) { - const actor = Context.get('actor'); - const app = actor.type?.app ?? undefined; - const user = actor.type?.user ?? undefined; - if ( ! user ) { - throw new Error('User not found'); - } - const key_hash = murmurhash.v3(key); - await this.#db.write('DELETE FROM kv WHERE user_id=? AND app=? AND kkey_hash=?', [ - user.id, - app?.uid ?? GLOBAL_APP_KEY, - key_hash, - ]); - await this.#meteringService.incrementUsage(actor, 'kv:write', 1); - return true; - } - async list ({ as }) { - const actor = Context.get('actor'); - const app = actor.type?.app ?? undefined; - const user = actor.type?.user ?? undefined; - if ( ! user ) { - throw new Error('User not found'); - } - let rows = app - ? await this.#db.read('SELECT kkey, value, expireAt FROM kv WHERE user_id=? AND app=?', [user.id, app.uid]) - : await this.#db.read(`SELECT kkey, value, expireAt FROM kv WHERE user_id=? AND (app IS NULL OR app = '${GLOBAL_APP_KEY}')`, [user.id]); - rows = rows.filter((row) => { - return !row?.expireAt || row?.expireAt > Date.now() / 1000; - }); - rows = rows.map((row) => ({ - key: row.kkey, - value: this.#db.case({ - mysql: () => row.value, - otherwise: () => JSON.parse(row.value ?? 'null'), - })(), - })); - as = as || 'entries'; - if ( ! ['keys', 'values', 'entries'].includes(as) ) { - throw APIError.create('field_invalid', undefined, { - key: 'as', - expected: '"keys", "values", or "entries"', - }); - } - if ( as === 'keys' ) { - rows = rows.map((row) => row.key); - } - else if ( as === 'values' ) { - rows = rows.map((row) => row.value); - } - await this.#meteringService.incrementUsage(actor, 'kv:read', rows.length); - return rows; - } - async flush () { - const actor = Context.get('actor'); - const app = actor.type?.app ?? undefined; - const user = actor.type?.user ?? undefined; - if ( ! user ) { - throw new Error('User not found'); - } - await this.#db.write('DELETE FROM kv WHERE user_id=? AND app=?', [ - user.id, - app?.uid ?? GLOBAL_APP_KEY, - ]); - await this.#meteringService.incrementUsage(actor, 'kv:write', 1); - return true; - } - async expireAt ({ key, timestamp }) { - if ( key === '' ) { - throw APIError.create('field_empty', undefined, { - key: 'key', - }); - } - timestamp = Number(timestamp); - return await this.#expireat(key, timestamp); - } - async expire ({ key, ttl }) { - if ( key === '' ) { - throw APIError.create('field_empty', undefined, { - key: 'key', - }); - } - ttl = Number(ttl); - let timestamp = Math.floor(Date.now() / 1000) + ttl; - return await this.#expireat(key, timestamp); - } - async incr ({ key, pathAndAmountMap }) { - if ( Object.values(pathAndAmountMap).find((v) => typeof v !== 'number') ) { - throw new Error('All values in pathAndAmountMap must be numbers'); - } - let currVal = await this.get({ key }); - const pathEntries = Object.entries(pathAndAmountMap); - if ( typeof currVal !== 'object' && pathEntries.length <= 1 && !pathEntries[0]?.[0] ) { - const amount = pathEntries[0]?.[1] ?? 1; - this.set({ key, value: (Number(currVal) || 0) + amount }); - return ((Number(currVal) || 0) + amount); - } - if ( Array.isArray(currVal) ) { - throw new Error('Current value is an array'); - } - if ( ! currVal ) { - currVal = {}; - } - if ( typeof currVal !== 'object' ) { - throw new Error('Current value is not an object'); - } - for ( const [path, amount] of Object.entries(pathAndAmountMap) ) { - const pathParts = path.split('.'); - let obj = currVal; - if ( obj === null ) - { - continue; - } - for ( let i = 0; i < pathParts.length - 1; i++ ) { - const part = pathParts[i]; - if ( ! obj[part] ) { - obj[part] = {}; - } - if ( typeof obj[part] !== 'object' || Array.isArray(currVal) ) { - throw new Error(`Path ${pathParts.slice(0, i + 1).join('.')} is not an object`); - } - obj = obj[part]; - } - if ( obj === null ) - { - continue; - } - const lastPart = pathParts[pathParts.length - 1]; - if ( ! obj[lastPart] ) { - obj[lastPart] = 0; - } - if ( typeof obj[lastPart] !== 'number' ) { - throw new Error(`Value at path ${path} is not a number`); - } - obj[lastPart] += amount; - } - this.set({ key, value: currVal }); - return currVal; - } - async decr ({ key, pathAndAmountMap }) { - return this.incr({ key, pathAndAmountMap: Object.fromEntries(Object.entries(pathAndAmountMap).map(([k, v]) => [k, -v])) }); - } - async #expireat (key, timestamp) { - const actor = Context.get('actor'); - const app = actor.type?.app ?? undefined; - const user = actor.type?.user ?? undefined; - if ( ! user ) { - throw new Error('User not found'); - } - const key_hash = murmurhash.v3(key); - try { - await this.#db.write(`INSERT INTO kv (user_id, app, kkey_hash, kkey, value, expireAt) - VALUES (?, ?, ?, ?, ?, ?) ${this.#db.case({ - mysql: 'ON DUPLICATE KEY UPDATE expireAt = ?', - sqlite: 'ON CONFLICT(user_id, app, kkey_hash) DO UPDATE SET expireAt = excluded.expireAt', - })}`, [ - user.id, - app?.uid ?? GLOBAL_APP_KEY, - key_hash, - key, - undefined, - timestamp, - ...this.#db.case({ mysql: [timestamp], otherwise: [] }), - ]); - } - catch (e) { - console.error(e); - } - } -} -//# sourceMappingURL=DBKVStore.js.map \ No newline at end of file diff --git a/src/backend/src/services/repositories/DDBClient.js b/src/backend/src/services/repositories/DDBClient.js deleted file mode 100644 index b4e0324b9..000000000 --- a/src/backend/src/services/repositories/DDBClient.js +++ /dev/null @@ -1,196 +0,0 @@ -import { CreateTableCommand, DynamoDBClient, UpdateTimeToLiveCommand } from '@aws-sdk/client-dynamodb'; -import { BatchGetCommand, DeleteCommand, DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; -import { NodeHttpHandler } from '@smithy/node-http-handler'; -import dynalite from 'dynalite'; -import { once } from 'node:events'; -import { Agent as httpsAgent } from 'node:https'; -export class DDBClient { - ddbClientPromise; - #documentClient; - config; - constructor (config) { - this.config = config; - this.ddbClientPromise = this.#getClient(); - this.ddbClientPromise.then(client => { - this.#documentClient = DynamoDBDocumentClient.from(client, { - marshallOptions: { - removeUndefinedValues: true, - }, - }); - }); - } - async recreateClient () { - this.ddbClientPromise = this.#getClient(); - this.#documentClient = DynamoDBDocumentClient.from(await this.ddbClientPromise, { - marshallOptions: { - removeUndefinedValues: true, - }, - }); - } - async #getClient () { - if ( ! this.config?.aws ) { - console.warn('No config for DynamoDB, will fall back on local dynalite'); - const dynaliteInstance = dynalite({ createTableMs: 0, path: this.config?.path === ':memory:' ? undefined : this.config?.path || './puter-ddb' }); - const dynaliteServer = dynaliteInstance.listen(0, '127.0.0.1'); - await once(dynaliteServer, 'listening'); - const address = dynaliteServer.address(); - const port = (typeof address === 'object' && address ? address.port : undefined) || 4567; - const dynamoEndpoint = `http://127.0.0.1:${port}`; - return new DynamoDBClient({ - credentials: { - accessKeyId: 'fake', - secretAccessKey: 'fake', - }, - maxAttempts: 3, - requestHandler: new NodeHttpHandler({ - connectionTimeout: 5000, - requestTimeout: 5000, - httpsAgent: new httpsAgent({ keepAlive: true }), - }), - endpoint: dynamoEndpoint, - region: 'us-west-2', - }); - } - return new DynamoDBClient({ - credentials: { - accessKeyId: this.config.aws.access_key, - secretAccessKey: this.config.aws.secret_key, - }, - maxAttempts: 3, - requestHandler: new NodeHttpHandler({ - connectionTimeout: 5000, - requestTimeout: 5000, - httpsAgent: new httpsAgent({ keepAlive: true }), - }), - ...(this.config.endpoint ? { endpoint: this.config.endpoint } : {}), - region: this.config.aws.region || 'us-west-2', - }); - } - async get (table, key, consistentRead = false) { - const command = new GetCommand({ - TableName: table, - Key: key, - ConsistentRead: consistentRead, - ReturnConsumedCapacity: 'TOTAL', - }); - const response = await this.#documentClient.send(command); - return response; - } - async put (table, item) { - const command = new PutCommand({ - TableName: table, - Item: item, - ReturnConsumedCapacity: 'TOTAL', - }); - const response = await this.#documentClient.send(command); - return response; - } - async batchGet (params, consistentRead = false) { - const allRequestItemsPerTable = params.reduce((acc, curr) => { - if ( ! acc[curr.table] ) - { - acc[curr.table] = []; - } - acc[curr.table].push(curr.items); - return acc; - }, {}); - const RequestItems = Object.entries(allRequestItemsPerTable).reduce((acc, [table, keyList]) => { - const Keys = keyList; - acc[table] = { - Keys, - ConsistentRead: consistentRead, - }; - return acc; - }, {}); - const command = new BatchGetCommand({ - RequestItems, - ReturnConsumedCapacity: 'TOTAL', - }); - return this.#documentClient.send(command); - } - async del (table, key) { - const command = new DeleteCommand({ - TableName: table, - Key: key, - ReturnConsumedCapacity: 'TOTAL', - }); - return this.#documentClient.send(command); - } - async query (table, keys, limit = 0, pageKey, index = '', consistentRead = false, options) { - const keyExpressionParts = Object.keys(keys).map(key => `#${key} = :${key}`); - const expressionAttributeValues = Object.entries(keys).reduce((acc, [key, value]) => { - acc[`:${key}`] = value; - return acc; - }, {}); - const expressionAttributeNames = Object.keys(keys).reduce((acc, key) => { - acc[`#${key}`] = key; - return acc; - }, {}); - if ( options?.beginsWith?.key && typeof options.beginsWith.value === 'string' && options.beginsWith.value !== '' ) { - const beginsKey = options.beginsWith.key; - const beginsValueToken = `:${beginsKey}_begins_with`; - keyExpressionParts.push(`begins_with(#${beginsKey}, ${beginsValueToken})`); - expressionAttributeValues[beginsValueToken] = options.beginsWith.value; - expressionAttributeNames[`#${beginsKey}`] = beginsKey; - } - const keyExpression = keyExpressionParts.join(' AND '); - const command = new QueryCommand({ - TableName: table, - ...(!index ? {} : { IndexName: index }), - KeyConditionExpression: keyExpression, - ExpressionAttributeValues: expressionAttributeValues, - ExpressionAttributeNames: expressionAttributeNames, - ConsistentRead: consistentRead, - ...(!pageKey ? {} : { ExclusiveStartKey: pageKey }), - ...(!limit ? {} : { Limit: limit }), - ReturnConsumedCapacity: 'TOTAL', - }); - return await this.#documentClient.send(command); - } - async update (table, key, expression, expressionValues, expressionNames) { - const hasValues = !!expressionValues && Object.keys(expressionValues).length > 0; - const hasNames = !!expressionNames && Object.keys(expressionNames).length > 0; - const command = new UpdateCommand({ - TableName: table, - Key: key, - UpdateExpression: expression, - ...(hasValues ? { ExpressionAttributeValues: expressionValues } : {}), - ...(hasNames ? { ExpressionAttributeNames: expressionNames } : {}), - ReturnValues: 'ALL_NEW', - ReturnConsumedCapacity: 'TOTAL', - }); - try { - return await this.#documentClient.send(command); - } - catch (e) { - console.error('DDB Update Error', e); - throw e; - } - } - async createTableIfNotExists (params, ttlAttribute) { - if ( this.config?.aws ) { - console.warn('Creating DynamoDB tables in AWS is disabled by default, but if you need to enable it, modify the DDBClient class'); - return; - } - try { - await this.#documentClient.send(new CreateTableCommand(params)); - } - catch (e) { - if ( e?.name !== 'ResourceInUseException' ) { - throw e; - } - setTimeout(async () => { - if ( ttlAttribute ) { - await this.#documentClient.send(new UpdateTimeToLiveCommand({ - TableName: params.TableName, - TimeToLiveSpecification: { - AttributeName: ttlAttribute, - Enabled: true, - }, - })); - } - }, 5000); - } - } -} -//# sourceMappingURL=DDBClient.js.map \ No newline at end of file diff --git a/src/backend/src/services/repositories/DDBClientWrapper.js b/src/backend/src/services/repositories/DDBClientWrapper.js deleted file mode 100644 index 426dc7b9e..000000000 --- a/src/backend/src/services/repositories/DDBClientWrapper.js +++ /dev/null @@ -1,18 +0,0 @@ -import { BaseService } from '@heyputer/backend/src/services/BaseService.js'; -import { DDBClient } from './DDBClient.js'; -class DDBClientServiceWrapper extends BaseService { - ddbClient; - async _construct () { - this.ddbClient = new DDBClient(this.config); - await this.ddbClient.ddbClientPromise; - Object.getOwnPropertyNames(DDBClient.prototype).forEach(fn => { - if ( fn === 'constructor' ) - { - return; - } - this[fn] = (...args) => this.ddbClient[fn](...args); - }); - } -} -export const DDBClientWrapper = DDBClientServiceWrapper; -//# sourceMappingURL=DDBClientWrapper.js.map \ No newline at end of file diff --git a/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.js b/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.js deleted file mode 100644 index 687e00de5..000000000 --- a/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.js +++ /dev/null @@ -1,647 +0,0 @@ -import { SystemActorType } from '@heyputer/backend/src/services/auth/Actor.js'; -import { Context } from '@heyputer/backend/src/util/context.js'; -import murmurhash from 'murmurhash'; -import { PUTER_KV_STORE_TABLE_DEFINITION } from './tableDefinition.js'; -import APIError from '../../../api/APIError.js'; -export class DynamoKVStore { - static GLOBAL_APP_KEY = 'os-global'; - static LEGACY_GLOBAL_APP_KEY = 'global'; - #ddbClient; - #sqlClient; - #meteringService; - #tableName = 'store-kv-v1'; - #pathCleanerRegex = /[:\-+/*]/g; - #enableMigrationFromSQL = false; - constructor ({ ddbClient, sqlClient, tableName, meteringService }) { - this.#ddbClient = ddbClient; - this.#sqlClient = sqlClient; - this.#tableName = tableName; - this.#meteringService = meteringService; - this.#enableMigrationFromSQL = !this.#ddbClient.config?.aws; - } - async createTableIfNotExists () { - if ( ! this.#enableMigrationFromSQL ) - { - return; - } - await this.#ddbClient.createTableIfNotExists({ ...PUTER_KV_STORE_TABLE_DEFINITION, TableName: this.#tableName }, 'ttl'); - } - #getNameSpace (actor) { - if ( actor.type instanceof SystemActorType ) { - return 'v1:system'; - } - else { - const app = actor.type?.app ?? undefined; - const user = actor.type?.user ?? undefined; - if ( ! user ) - { - throw new Error('User not found'); - } - return `v1:${app ? `${user.uuid}:${app.uid}` - : `${user.uuid}:${this.#enableMigrationFromSQL ? DynamoKVStore.LEGACY_GLOBAL_APP_KEY : DynamoKVStore.GLOBAL_APP_KEY}`}`; - } - } - async get ({ key }) { - if ( key === '' ) { - throw APIError.create('field_empty', null, { - key: 'key', - }); - } - const actor = Context.get('actor'); - const app = actor.type?.app ?? undefined; - const user = actor.type?.user ?? undefined; - const namespace = this.#getNameSpace(actor); - const multi = Array.isArray(key); - const keys = multi ? key : [key]; - const values = []; - let kvEntries; - let usage; - if ( multi ) { - const entriesAndUsage = (await this.#getBatches(namespace, keys)); - kvEntries = entriesAndUsage.kvEntries; - usage = entriesAndUsage.usage; - } - else { - const res = await this.#ddbClient.get(this.#tableName, { namespace, key }); - kvEntries = res.Item ? [res.Item] : []; - usage = res.ConsumedCapacity?.CapacityUnits ?? 0; - } - this.#meteringService.incrementUsage(actor, 'kv:read', usage || 0); - for ( const key of keys ) { - const kv_entry = kvEntries?.find(e => e.key === key); - const time = Date.now() / 1000; - if ( kv_entry?.ttl && kv_entry.ttl <= (time) ) { - values.push(null); - continue; - } - if ( kv_entry?.value ) { - values.push(kv_entry.value); - continue; - } - if ( this.#enableMigrationFromSQL ) { - const key_hash = murmurhash.v3(key); - const kv_row = await this.#sqlClient.read('SELECT * FROM kv WHERE user_id=? AND app=? AND kkey_hash=? LIMIT 1', [user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY, key_hash]); - if ( kv_row[0]?.value ) { - (async () => { - await this.set({ key: kv_row[0].key, value: kv_row[0].value }); - await this.#sqlClient.write('DELETE FROM kv WHERE user_id=? AND app=? AND kkey_hash=?', [user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY, key_hash]); - })(); - values.push(kv_row[0]?.value); - continue; - } - } - values.push(kv_entry?.value ?? null); - } - return multi ? values : values[0]; - } - async #getBatches (namespace, allKeys) { - const batches = []; - for ( let i = 0; i < allKeys.length; i += 100 ) { - batches.push(allKeys.slice(i, i + 100)); - } - const batchPromises = batches.map(async (keys) => { - const requests = [...new Set(keys)].map(k => ({ table: this.#tableName, items: { namespace, key: k } })); - const res = await this.#ddbClient.batchGet(requests); - const kvEntries = res.Responses?.[this.#tableName]; - const usage = res.ConsumedCapacity?.reduce((acc, curr) => acc + (curr.CapacityUnits ?? 0), 0); - return { kvEntries, usage }; - }); - const batchGets = await Promise.all(batchPromises); - return batchGets.reduce((acc, curr) => { - acc.kvEntries.push(...curr?.kvEntries ?? []); - acc.usage += curr.usage || 0; - return acc; - }, { kvEntries: [], usage: 0 }); - } - async set ({ key, value, expireAt }) { - const context = Context.get(); - const actor = context.get('actor'); - if ( key === '' ) { - throw APIError.create('field_empty', undefined, { - key: 'key', - }); - } - key = String(key); - if ( Buffer.byteLength(key, 'utf8') > 1024 ) { - throw new Error(`key is too large. Max size is ${1024}.`); - } - if ( this.#enableMigrationFromSQL ) { - this.get({ key }); - } - const namespace = this.#getNameSpace(actor); - const res = await this.#ddbClient.put(this.#tableName, { - namespace, - key, - value, - ttl: expireAt, - }); - this.#meteringService.incrementUsage(actor, 'kv:write', res?.ConsumedCapacity?.CapacityUnits ?? 1); - return true; - } - async del ({ key }) { - const actor = Context.get('actor'); - const app = actor.type?.app ?? undefined; - const user = actor.type?.user ?? undefined; - if ( ! user ) - { - throw new Error('User not found'); - } - const namespace = this.#getNameSpace(actor); - const res = await this.#ddbClient.del(this.#tableName, { - namespace, - key, - }); - this.#meteringService.incrementUsage(actor, 'kv:write', res?.ConsumedCapacity?.CapacityUnits ?? 1); - if ( this.#enableMigrationFromSQL ) { - const key_hash = murmurhash.v3(key); - await this.#sqlClient.write('DELETE FROM kv WHERE user_id=? AND app=? AND kkey_hash=?', [user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY, key_hash]); - } - return true; - } - #encodeCursor (pageKey) { - if ( !pageKey || Object.keys(pageKey).length === 0 ) { - return undefined; - } - return Buffer.from(JSON.stringify(pageKey)).toString('base64'); - } - #decodeCursor (cursor) { - if ( ! cursor ) { - return undefined; - } - if ( typeof cursor === 'object' ) { - return cursor; - } - if ( typeof cursor !== 'string' ) { - throw APIError.create('field_invalid', undefined, { - key: 'cursor', - }); - } - const trimmed = cursor.trim(); - if ( trimmed === '' ) { - return undefined; - } - try { - const decoded = Buffer.from(trimmed, 'base64').toString('utf8'); - return JSON.parse(decoded); - } - catch (e) { - try { - return JSON.parse(trimmed); - } - catch ( err ) { - throw APIError.create('field_invalid', undefined, { - key: 'cursor', - }); - } - } - } - #normalizeLimit (limit) { - if ( limit === undefined || limit === null ) { - return undefined; - } - const parsed = Number(limit); - if ( !Number.isFinite(parsed) || parsed <= 0 ) { - throw APIError.create('field_invalid', undefined, { - key: 'limit', - expected: 'positive number', - }); - } - return Math.floor(parsed); - } - #normalizePattern (pattern) { - if ( pattern === undefined || pattern === null ) { - return undefined; - } - if ( typeof pattern !== 'string' ) { - throw APIError.create('field_invalid', undefined, { - key: 'pattern', - }); - } - const trimmed = pattern.trim(); - if ( trimmed === '' ) { - return undefined; - } - if ( trimmed.endsWith('*') ) { - const prefix = trimmed.slice(0, -1); - return prefix === '' ? undefined : prefix; - } - return trimmed; - } - async list ({ as, limit, cursor, pattern }) { - const actor = Context.get('actor'); - const app = actor.type?.app ?? undefined; - const user = actor.type?.user ?? undefined; - if ( ! user ) - { - throw new Error('User not found'); - } - const namespace = this.#getNameSpace(actor); - const normalizedLimit = this.#normalizeLimit(limit); - const pageKey = this.#decodeCursor(cursor); - const normalizedPattern = this.#normalizePattern(pattern); - const paginated = normalizedLimit !== undefined || pageKey !== undefined; - const entriesRes = await this.#ddbClient.query(this.#tableName, { namespace }, normalizedLimit ?? 0, pageKey, '', false, normalizedPattern ? { beginsWith: { key: 'key', value: normalizedPattern } } : undefined); - this.#meteringService.incrementUsage(actor, 'kv:read', entriesRes.ConsumedCapacity?.CapacityUnits ?? 1); - let entries = entriesRes.Items ?? []; - entries = entries?.filter(entry => { - if ( ! entry ) { - return false; - } - if ( entry.ttl && entry.ttl <= (Date.now() / 1000) ) { - return false; - } - return true; - }); - if ( this.#enableMigrationFromSQL && !paginated ) { - const oldEntries = await this.#sqlClient.read('SELECT * FROM kv WHERE user_id=? AND app=?', [user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY]); - oldEntries.forEach(oldEntry => { - if ( normalizedPattern && !oldEntry.kkey?.startsWith(normalizedPattern) ) { - return; - } - if ( ! entries.find(e => e.key === oldEntry.kkey) ) { - if ( oldEntry.ttl && oldEntry.ttl <= (Date.now() / 1000) ) { - entries.push({ key: oldEntry.kkey, value: oldEntry.value }); - } - } - }); - } - entries = entries?.map(entry => ({ - key: entry.key, - value: entry.value, - })); - as = as || 'entries'; - if ( ! ['keys', 'values', 'entries'].includes(as) ) { - throw APIError.create('field_invalid', undefined, { - key: 'as', - expected: '"keys", "values", or "entries"', - }); - } - let items = entries; - if ( as === 'keys' ) - { - items = entries.map(entry => entry.key); - } - else if ( as === 'values' ) - { - items = entries.map(entry => entry.value); - } - if ( paginated ) { - const nextCursor = this.#encodeCursor(entriesRes.LastEvaluatedKey); - if ( nextCursor ) { - return { items, cursor: nextCursor }; - } - return { items }; - } - return items; - } - async flush () { - const actor = Context.get('actor'); - const app = actor.type.app ?? undefined; - const user = actor.type?.user ?? undefined; - if ( ! user ) - { - throw new Error('User not found'); - } - const namespace = this.#getNameSpace(actor); - const entriesRes = await this.#ddbClient.query(this.#tableName, { namespace }); - const entries = entriesRes.Items ?? []; - const readUsage = entriesRes?.ConsumedCapacity?.CapacityUnits ?? 0; - this.#meteringService.incrementUsage(actor, 'kv:read', readUsage); - const allRes = (await Promise.all(entries.map(entry => { - try { - return this.#ddbClient.del(this.#tableName, { - namespace, - key: entry.key, - }); - } - catch (e) { - console.error('Error deleting key', entry.key, e); - } - }))).filter(Boolean); - const writeUsage = allRes.reduce((acc, curr) => acc + (curr?.ConsumedCapacity?.CapacityUnits ?? 0), 0); - this.#meteringService.incrementUsage(actor, 'kv:write', writeUsage); - if ( this.#enableMigrationFromSQL ) { - await this.#sqlClient.write('DELETE FROM kv WHERE user_id=? AND app=?', [user.id, app?.uid ?? DynamoKVStore.LEGACY_GLOBAL_APP_KEY]); - } - return !!allRes; - } - async expireAt ({ key, timestamp }) { - if ( key === '' ) { - throw APIError.create('field_empty', null, { - key: 'key', - }); - } - timestamp = Number(timestamp); - return await this.#expireAt(key, timestamp); - } - async expire ({ key, ttl }) { - if ( key === '' ) { - throw APIError.create('field_empty', null, { - key: 'key', - }); - } - ttl = Number(ttl); - let timestamp = Math.floor(Date.now() / 1000) + ttl; - return await this.#expireAt(key, timestamp); - } - async #createPaths (namespace, key, pathList) { - const nestedMapValue = (() => { - const valueRoot = {}; - let hasPaths = false; - pathList.forEach((valPath) => { - if ( ! valPath ) - { - return; - } - hasPaths = true; - const chunks = valPath.split('.').filter(Boolean); - let cursor = valueRoot; - for ( let i = 0; i < chunks.length - 1; i++ ) { - const chunk = chunks[i]; - const existing = cursor[chunk]; - if ( !existing || typeof existing !== 'object' || Array.isArray(existing) ) { - cursor[chunk] = {}; - } - cursor = cursor[chunk]; - } - }); - return hasPaths ? valueRoot : null; - })(); - if ( ! nestedMapValue ) { - return 0; - } - const isPlainObject = (value) => { - return !!value && typeof value === 'object' && !Array.isArray(value); - }; - const objectsEqual = (left, right) => { - if ( left === right ) - { - return true; - } - if ( !isPlainObject(left) || !isPlainObject(right) ) - { - return false; - } - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - if ( leftKeys.length !== rightKeys.length ) - { - return false; - } - for ( const key of leftKeys ) { - if ( ! rightKeys.includes(key) ) - { - return false; - } - if ( ! objectsEqual(left[key], right[key]) ) - { - return false; - } - } - return true; - }; - const allIntermediatePaths = new Set(); - pathList.forEach((valPath) => { - const chunks = ['value', ...valPath.split('.')].filter(Boolean); - for ( let i = 1; i < chunks.length; i++ ) { - const subPath = chunks.slice(0, i).join('.'); - allIntermediatePaths.add(subPath); - } - }); - let writeUnits = 0; - const orderedPaths = [...allIntermediatePaths] - .sort((left, right) => left.split('.').length - right.split('.').length); - for ( const layerPath of orderedPaths ) { - const chunks = layerPath.split('.'); - const attrName = chunks.map((chunk) => `#${chunk}`.replaceAll(this.#pathCleanerRegex, '')).join('.'); - const expressionNames = {}; - chunks.forEach((chunk) => { - const cleanedChunk = chunk.split(/\[\d*\]/g)[0]; - expressionNames[`#${cleanedChunk}`.replaceAll(this.#pathCleanerRegex, '')] = cleanedChunk; - }); - const isRootLayer = layerPath === 'value'; - const expressionValues = isRootLayer - ? { ':nestedMap': nestedMapValue } - : { ':emptyMap': {} }; - const valueToken = isRootLayer ? ':nestedMap' : ':emptyMap'; - const layerUpsertRes = await this.#ddbClient.update(this.#tableName, { key, namespace }, `SET ${attrName} = if_not_exists(${attrName}, ${valueToken})`, expressionValues, expressionNames); - writeUnits += layerUpsertRes.ConsumedCapacity?.CapacityUnits ?? 0; - if ( isRootLayer && objectsEqual(layerUpsertRes.Attributes?.value, nestedMapValue) ) { - return writeUnits; - } - } - return writeUnits; - } - async incr ({ key, pathAndAmountMap }) { - if ( Object.values(pathAndAmountMap).find((v) => typeof v !== 'number') ) { - throw new Error('All values in pathAndAmountMap must be numbers'); - } - if ( key === '' ) { - throw APIError.create('field_empty', null, { - key: 'key', - }); - } - if ( ! pathAndAmountMap ) { - throw new Error('invalid use of #incr: no pathAndAmountMap'); - } - const actor = Context.get('actor'); - const user = actor.type?.user ?? undefined; - if ( ! user ) - { - throw new Error('User not found'); - } - const namespace = this.#getNameSpace(actor); - if ( this.#enableMigrationFromSQL ) { - await this.get({ key }); - } - const cleanerRegex = /[:\-+/*]/g; - let writeUnits = await this.#createPaths(namespace, key, Object.keys(pathAndAmountMap)); - const setStatements = Object.entries(pathAndAmountMap).map(([valPath, _amt], idx) => { - const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); - const attrName = path.split('.').map((chunk) => `#${chunk}`.replaceAll(cleanerRegex, '')).join('.'); - return `${attrName} = if_not_exists(${attrName}, :start${idx}) + :incr${idx}`; - }); - const valueAttributeValues = Object.entries(pathAndAmountMap).reduce((acc, [_path, amt], idx) => { - acc[`:incr${idx}`] = amt; - acc[`:start${idx}`] = 0; - return acc; - }, {}); - const valueAttributeNames = Object.entries(pathAndAmountMap).reduce((acc, [valPath, _amt]) => { - const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); - path.split('.').forEach((chunk) => { - const cleanedChunk = chunk.split(/\[\d*\]/g)[0]; - acc[`#${cleanedChunk}`.replaceAll(cleanerRegex, '')] = cleanedChunk; - }); - return acc; - }, {}); - const res = await this.#ddbClient.update(this.#tableName, { key, namespace }, `SET ${[...setStatements].join(', ')}`, valueAttributeValues, { ...valueAttributeNames, '#value': 'value' }); - writeUnits += res.ConsumedCapacity?.CapacityUnits ?? 0; - this.#meteringService.incrementUsage(actor, 'kv:write', writeUnits); - return res.Attributes?.value; - } - async add ({ key, pathAndValueMap }) { - if ( !pathAndValueMap || Object.keys(pathAndValueMap).length === 0 ) { - throw new Error('invalid use of #add: no pathAndValueMap'); - } - if ( key === '' ) { - throw APIError.create('field_empty', null, { - key: 'key', - }); - } - const actor = Context.get('actor'); - const user = actor.type?.user ?? undefined; - if ( ! user ) - { - throw new Error('User not found'); - } - const namespace = this.#getNameSpace(actor); - if ( this.#enableMigrationFromSQL ) { - await this.get({ key }); - } - const cleanerRegex = /[:\-+/*]/g; - let writeUnits = await this.#createPaths(namespace, key, Object.keys(pathAndValueMap)); - const setStatements = Object.entries(pathAndValueMap).map(([valPath, _val], idx) => { - const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); - const attrName = path.split('.').map((chunk) => `#${chunk}`.replaceAll(cleanerRegex, '')).join('.'); - return `${attrName} = list_append(if_not_exists(${attrName}, :emptyList${idx}), :append${idx})`; - }); - const valueAttributeValues = Object.entries(pathAndValueMap).reduce((acc, [_path, val], idx) => { - acc[`:append${idx}`] = Array.isArray(val) ? val : [val]; - acc[`:emptyList${idx}`] = []; - return acc; - }, {}); - const valueAttributeNames = Object.entries(pathAndValueMap).reduce((acc, [valPath, _val]) => { - const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); - path.split('.').forEach((chunk) => { - const cleanedChunk = chunk.split(/\[\d*\]/g)[0]; - acc[`#${cleanedChunk}`.replaceAll(cleanerRegex, '')] = cleanedChunk; - }); - return acc; - }, {}); - const res = await this.#ddbClient.update(this.#tableName, { key, namespace }, `SET ${[...setStatements].join(', ')}`, valueAttributeValues, { ...valueAttributeNames, '#value': 'value' }); - writeUnits += res.ConsumedCapacity?.CapacityUnits ?? 0; - this.#meteringService.incrementUsage(actor, 'kv:write', writeUnits); - return res.Attributes?.value; - } - async remove ({ key, paths }) { - if ( !paths || paths.length === 0 ) { - throw new Error('invalid use of #remove: no paths'); - } - if ( key === '' ) { - throw APIError.create('field_empty', null, { - key: 'key', - }); - } - const actor = Context.get('actor'); - const user = actor.type?.user ?? undefined; - if ( ! user ) - { - throw new Error('User not found'); - } - const namespace = this.#getNameSpace(actor); - if ( this.#enableMigrationFromSQL ) { - await this.get({ key }); - } - const cleanerRegex = /[:\-+/*]/g; - const removeStatements = paths.map((valPath) => { - const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); - return path.split('.').map((chunk) => { - const cleanedChunk = chunk.split(/\[\d*\]/g)[0]; - const indexSuffix = chunk.slice(cleanedChunk.length); - return `${`#${cleanedChunk}`.replaceAll(cleanerRegex, '')}${indexSuffix}`; - }).join('.'); - }); - const valueAttributeNames = paths.reduce((acc, valPath) => { - const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); - path.split('.').forEach((chunk) => { - const cleanedChunk = chunk.split(/\[\d*\]/g)[0]; - acc[`#${cleanedChunk}`.replaceAll(cleanerRegex, '')] = cleanedChunk; - }); - return acc; - }, {}); - try { - const res = await this.#ddbClient.update(this.#tableName, { key, namespace }, `REMOVE ${removeStatements.join(', ')}`, undefined, { ...valueAttributeNames, '#value': 'value' }); - this.#meteringService.incrementUsage(actor, 'kv:write', res?.ConsumedCapacity?.CapacityUnits ?? 1); - return res.Attributes?.value; - } - catch (e) { - const message = e?.message ?? ''; - if ( e?.name === 'ValidationException' && /document path|invalid updateexpression/i.test(message) ) { - this.#meteringService.incrementUsage(actor, 'kv:write', 1); - return await this.get({ key }); - } - throw e; - } - } - async update ({ key, pathAndValueMap, ttl }) { - if ( !pathAndValueMap || Object.keys(pathAndValueMap).length === 0 ) { - throw new Error('invalid use of #update: no pathAndValueMap'); - } - if ( key === '' ) { - throw APIError.create('field_empty', null, { - key: 'key', - }); - } - const actor = Context.get('actor'); - const user = actor.type?.user ?? undefined; - if ( ! user ) - { - throw new Error('User not found'); - } - const namespace = this.#getNameSpace(actor); - if ( this.#enableMigrationFromSQL ) { - await this.get({ key }); - } - const cleanerRegex = /[:\-+/*]/g; - let writeUnits = await this.#createPaths(namespace, key, Object.keys(pathAndValueMap)); - const setStatements = Object.entries(pathAndValueMap).map(([valPath, _val], idx) => { - const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); - const attrName = path.split('.').map((chunk) => `#${chunk}`.replaceAll(cleanerRegex, '')).join('.'); - return `${attrName} = :value${idx}`; - }); - const valueAttributeValues = Object.entries(pathAndValueMap).reduce((acc, [_path, val], idx) => { - acc[`:value${idx}`] = val; - return acc; - }, {}); - const valueAttributeNames = Object.entries(pathAndValueMap).reduce((acc, [valPath, _val]) => { - const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); - path.split('.').forEach((chunk) => { - const cleanedChunk = chunk.split(/\[\d*\]/g)[0]; - acc[`#${cleanedChunk}`.replaceAll(cleanerRegex, '')] = cleanedChunk; - }); - return acc; - }, {}); - if ( ttl !== undefined ) { - const ttlSeconds = Number(ttl); - if ( Number.isNaN(ttlSeconds) ) { - throw new Error('ttl must be a number'); - } - const timestamp = Math.floor(Date.now() / 1000) + ttlSeconds; - setStatements.push('#ttl = :ttl'); - valueAttributeValues[':ttl'] = timestamp; - valueAttributeNames['#ttl'] = 'ttl'; - } - const res = await this.#ddbClient.update(this.#tableName, { key, namespace }, `SET ${[...setStatements].join(', ')}`, valueAttributeValues, { ...valueAttributeNames, '#value': 'value' }); - writeUnits += res.ConsumedCapacity?.CapacityUnits ?? 0; - this.#meteringService.incrementUsage(actor, 'kv:write', writeUnits); - return res.Attributes?.value; - } - async decr ({ key, pathAndAmountMap }) { - return await this.incr({ key, pathAndAmountMap: Object.fromEntries(Object.entries(pathAndAmountMap).map(([k, v]) => [k, -v])) }); - } - async #expireAt (key, timestamp) { - const actor = Context.get('actor'); - const user = actor.type?.user ?? undefined; - if ( ! user ) - { - throw new Error('User not found'); - } - const namespace = this.#getNameSpace(actor); - if ( this.#enableMigrationFromSQL ) { - await this.get({ key }); - } - const res = await this.#ddbClient.update(this.#tableName, { key, namespace }, 'SET #ttl = :ttl, #value = if_not_exists(#value, :defaultValue)', { ':ttl': timestamp, ':defaultValue': null }, { '#ttl': 'ttl', '#value': 'value' }); - this.#meteringService.incrementUsage(actor, 'kv:write', res?.ConsumedCapacity?.CapacityUnits ?? 1); - } -} -//# sourceMappingURL=DynamoKVStore.js.map \ No newline at end of file diff --git a/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStoreWrapper.js b/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStoreWrapper.js deleted file mode 100644 index 46d6efaeb..000000000 --- a/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStoreWrapper.js +++ /dev/null @@ -1,55 +0,0 @@ -import { BaseService } from '@heyputer/backend/src/services/BaseService.js'; -import { DynamoKVStore } from './DynamoKVStore.js'; -class DynamoKVStoreServiceWrapper extends BaseService { - kvStore; - async _init () { - this.kvStore = new DynamoKVStore({ - ddbClient: this.services.get('dynamo'), - sqlClient: this.services.get('database').get(), - meteringService: this.services.get('meteringService').meteringService, - tableName: this.config.tableName || 'store-kv-v1', - }); - await this.kvStore.createTableIfNotExists(); - Object.getOwnPropertyNames(DynamoKVStore.prototype).forEach(fn => { - if ( fn === 'constructor' ) - { - return; - } - this[fn] = (...args) => this.kvStore[fn](...args); - }); - } - async registerHealthcheck () { - const healthcheckService = this.services.get('server-health'); - healthcheckService.add_check('kv-store', async () => { - try { - const passed = await this.services.get('su').sudo(async () => { - const rand = Math.floor(Math.random() * 1000000); - await this.kvStore.set({ key: 'healthTestKey', value: rand }); - const setRight = await this.kvStore.get({ key: 'healthTestKey' }) === rand; - await this.kvStore.del({ key: 'healthTestKey' }); - return setRight; - }); - if ( ! passed ) { - throw new Error('KV Store healthcheck failed: set/get mismatch'); - } - } - catch (e) { - throw new Error(`KV Store healthcheck failed: ${e.message}`); - } - }).on_fail(async () => { - await this.services.get('dynamo').recreateClient(); - }); - } - static IMPLEMENTS = { - 'puter-kvstore': Object.getOwnPropertyNames(DynamoKVStore.prototype) - .filter(n => n !== 'constructor') - .reduce((acc, fn) => ({ - ...acc, - [fn]: async function (...a) { - return await this.kvStore[fn](...a); - }, - }), {}), - }; -} -export const DynamoKVStoreWrapper = DynamoKVStoreServiceWrapper; -//# sourceMappingURL=DynamoKVStoreWrapper.js.map \ No newline at end of file diff --git a/src/backend/src/services/repositories/DynamoKVStore/tableDefinition.js b/src/backend/src/services/repositories/DynamoKVStore/tableDefinition.js deleted file mode 100644 index f6fcbdb11..000000000 --- a/src/backend/src/services/repositories/DynamoKVStore/tableDefinition.js +++ /dev/null @@ -1,24 +0,0 @@ -export const PUTER_KV_STORE_TABLE_DEFINITION = { - TableName: 'store-kv-v1', - BillingMode: 'PAY_PER_REQUEST', - AttributeDefinitions: [ - { AttributeName: 'namespace', AttributeType: 'S' }, - { AttributeName: 'key', AttributeType: 'S' }, - { AttributeName: 'lsi1', AttributeType: 'S' }, - ], - KeySchema: [ - { AttributeName: 'namespace', KeyType: 'HASH' }, - { AttributeName: 'key', KeyType: 'RANGE' }, - ], - LocalSecondaryIndexes: [ - { - IndexName: 'lsi1-index', - KeySchema: [ - { AttributeName: 'namespace', KeyType: 'HASH' }, - { AttributeName: 'lsi1', KeyType: 'RANGE' }, - ], - Projection: { ProjectionType: 'ALL' }, - }, - ], -}; -//# sourceMappingURL=tableDefinition.js.map \ No newline at end of file From 4de6d386045fdab5f010ae091c46f647a8085bb0 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:00:25 -0500 Subject: [PATCH 32/39] style(oidc): make this a private method --- .../web/UserProtectedEndpointsService.js | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/backend/src/services/web/UserProtectedEndpointsService.js b/src/backend/src/services/web/UserProtectedEndpointsService.js index 94481c739..b7c9ea480 100644 --- a/src/backend/src/services/web/UserProtectedEndpointsService.js +++ b/src/backend/src/services/web/UserProtectedEndpointsService.js @@ -29,15 +29,6 @@ const jwt = require('jsonwebtoken'); const REVALIDATION_COOKIE_NAME = 'puter_revalidation'; -async function revalidateUrlFields_ (svc, req, user) { - const origin = (config.origin || '').replace(/\/$/, ''); - const svc_oidc = req.services.get('oidc'); - const providers = await svc_oidc.getEnabledProviderIds(); - const provider = providers && providers[0]; - if ( ! provider ) return {}; - return { revalidate_url: `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_id=${user.id}` }; -} - /** * @class UserProtectedEndpointsService * @extends BaseService @@ -54,6 +45,15 @@ class UserProtectedEndpointsService extends BaseService { express: require('express'), }; + async #revalidateUrlFields (req, user) { + const origin = (config.origin || '').replace(/\/$/, ''); + const svc_oidc = req.services.get('oidc'); + const providers = await svc_oidc.getEnabledProviderIds(); + const provider = providers && providers[0]; + if ( ! provider ) return {}; + return { revalidate_url: `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_id=${user.id}` }; + } + /** * Sets up and configures routes for user-protected endpoints. * This method initializes an Express router, applies middleware for authentication, @@ -131,7 +131,7 @@ class UserProtectedEndpointsService extends BaseService { if ( req.body.password ) { if ( user.password === null ) { - return (APIError.create('oidc_revalidation_required', null, await revalidateUrlFields_(this, req, user))).write(res); + return (APIError.create('oidc_revalidation_required', null, await this.#revalidateUrlFields(req, user))).write(res); } const bcrypt = (() => { const require = this.require; @@ -156,7 +156,7 @@ class UserProtectedEndpointsService extends BaseService { } if ( user.password === null ) { - return (APIError.create('oidc_revalidation_required', null, await revalidateUrlFields_(this, req, user))).write(res); + return (APIError.create('oidc_revalidation_required', null, await this.#revalidateUrlFields(req, user))).write(res); } return (APIError.create('password_required')).write(res); }); From f3bf40a3c05f08878410432ab3d1d1ee5b81efc6 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:29:19 -0500 Subject: [PATCH 33/39] style(oidc): rename `httpPowers` to `hasHttpOnlyCookie` --- src/backend/src/middleware/configurable_auth.js | 4 ++-- src/backend/src/routers/login.js | 2 +- src/backend/src/routers/save_account.js | 2 +- src/backend/src/routers/signup.js | 2 +- src/backend/src/services/auth/Actor.d.ts | 4 ++-- src/backend/src/services/auth/Actor.js | 4 ++-- src/backend/src/services/auth/AuthService.js | 10 +++++----- .../src/services/web/UserProtectedEndpointsService.js | 2 +- src/gui/src/UI/UIWindowChangeUsername.js | 2 +- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/backend/src/middleware/configurable_auth.js b/src/backend/src/middleware/configurable_auth.js index 9880d60e1..f0a6da750 100644 --- a/src/backend/src/middleware/configurable_auth.js +++ b/src/backend/src/middleware/configurable_auth.js @@ -117,7 +117,7 @@ const configurable_auth = options => async (req, res, next) => { const services = context.get('services'); const svc_auth = services.get('auth'); - // Debug: log token source and decoded type before creating Actor (for session_required / hasHttpPowers debugging) + // Debug: log token source and decoded type before creating Actor (for session_required / hasHttpOnlyCookie debugging) if ( process.env.DEBUG ) { let decodedForLog; try { @@ -154,7 +154,7 @@ const configurable_auth = options => async (req, res, next) => { throw APIError.create('forbidden'); } - // Use session token in cookie so cookie-based requests have hasHttpPowers; client gets GUI token in response + // Use session token in cookie so cookie-based requests have hasHttpOnlyCookie; client gets GUI token in response res.cookie(config.cookie_name, new_info.session_token ?? new_info.token, { sameSite: 'none', secure: true, diff --git a/src/backend/src/routers/login.js b/src/backend/src/routers/login.js index 21948e518..521853e0e 100644 --- a/src/backend/src/routers/login.js +++ b/src/backend/src/routers/login.js @@ -29,7 +29,7 @@ const complete_ = async ({ req, res, user }) => { const { session, token: session_token } = await svc_auth.create_session_token(user, { req }); const gui_token = svc_auth.create_gui_token(user, session); - // HTTP-only cookie gets session token (cookie-based requests have hasHttpPowers) + // HTTP-only cookie gets session token (cookie-based requests have hasHttpOnlyCookie) res.cookie(config.cookie_name, session_token, { sameSite: 'none', secure: true, diff --git a/src/backend/src/routers/save_account.js b/src/backend/src/routers/save_account.js index 32471dc55..4f7b4b151 100644 --- a/src/backend/src/routers/save_account.js +++ b/src/backend/src/routers/save_account.js @@ -221,7 +221,7 @@ router.post('/save_account', auth, express.json(), async (req, res, next) => { // todo send LINK-based verification email - // HTTP-only cookie gets session token (cookie-based requests have hasHttpPowers) + // HTTP-only cookie gets session token (cookie-based requests have hasHttpOnlyCookie) res.cookie(config.cookie_name, session_token); { diff --git a/src/backend/src/routers/signup.js b/src/backend/src/routers/signup.js index 1bf9e4499..460cbd0d9 100644 --- a/src/backend/src/routers/signup.js +++ b/src/backend/src/routers/signup.js @@ -458,7 +458,7 @@ module.exports = eggspress(['/signup'], { const svc_user = Context.get('services').get('user'); await svc_user.generate_default_fsentries({ user }); - // HTTP-only cookie gets session token (cookie-based requests have hasHttpPowers) + // HTTP-only cookie gets session token (cookie-based requests have hasHttpOnlyCookie) res.cookie(config.cookie_name, session_token, { sameSite: 'none', secure: true, diff --git a/src/backend/src/services/auth/Actor.d.ts b/src/backend/src/services/auth/Actor.d.ts index 1b2c913a0..5b9b3640a 100644 --- a/src/backend/src/services/auth/Actor.d.ts +++ b/src/backend/src/services/auth/Actor.d.ts @@ -12,10 +12,10 @@ export class SystemActorType { } export class UserActorType { - constructor (params: { user: IUser; session?: { uuid: string }; hasHttpPowers?: boolean }); + constructor (params: { user: IUser; session?: { uuid: string }; hasHttpOnlyCookie?: boolean }); user: IUser; /** When true, this actor can access user-protected HTTP endpoints (e.g. change password). GUI tokens set this false. */ - hasHttpPowers: boolean; + hasHttpOnlyCookie: boolean; get uid (): string; get_related_type (type_class: unknown): UserActorType; } diff --git a/src/backend/src/services/auth/Actor.js b/src/backend/src/services/auth/Actor.js index 0feeb7b66..6afde7077 100644 --- a/src/backend/src/services/auth/Actor.js +++ b/src/backend/src/services/auth/Actor.js @@ -224,8 +224,8 @@ export class Actor extends AdvancedBase { export class UserActorType extends ActorType { constructor (o) { super(o); - if ( this.hasHttpPowers === undefined ) { - this.hasHttpPowers = false; + if ( this.hasHttpOnlyCookie === undefined ) { + this.hasHttpOnlyCookie = false; } } diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index 9b743c4e4..901cac2b9 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -107,7 +107,7 @@ class AuthService extends BaseService { const actor_type = new UserActorType({ user, session: session.uuid, - hasHttpPowers: true, + hasHttpOnlyCookie: true, }); return new Actor({ @@ -132,7 +132,7 @@ class AuthService extends BaseService { const actor_type = new UserActorType({ user, session: session.uuid, - hasHttpPowers: false, + hasHttpOnlyCookie: false, }); return new Actor({ @@ -338,7 +338,7 @@ class AuthService extends BaseService { /** * Creates a GUI token bound to the same session as the given session object. - * GUI tokens create a UserActorType with hasHttpPowers false, so they cannot + * GUI tokens create a UserActorType with hasHttpOnlyCookie false, so they cannot * access user-protected HTTP endpoints (e.g. change password). The GUI receives * only this token, not the full session token. * @@ -356,7 +356,7 @@ class AuthService extends BaseService { } /** - * Creates a session token (hasHttpPowers) for an existing session. + * Creates a session token (hasHttpOnlyCookie) for an existing session. * Used when the client authenticated with a GUI token (e.g. QR login via * ?auth_token=) so we can set the HTTP-only cookie and allow user-protected * endpoints (change password, email, username, etc.) to work. @@ -424,7 +424,7 @@ class AuthService extends BaseService { const actor_type = new UserActorType({ user, session, - hasHttpPowers: true, + hasHttpOnlyCookie: true, }); const actor = new Actor({ diff --git a/src/backend/src/services/web/UserProtectedEndpointsService.js b/src/backend/src/services/web/UserProtectedEndpointsService.js index b7c9ea480..1288eb004 100644 --- a/src/backend/src/services/web/UserProtectedEndpointsService.js +++ b/src/backend/src/services/web/UserProtectedEndpointsService.js @@ -95,7 +95,7 @@ class UserProtectedEndpointsService extends BaseService { if ( ! (actor.type instanceof UserActorType) ) { return APIError.create('user_tokens_only').write(res); } - if ( ! actor.type.hasHttpPowers ) { + if ( ! actor.type.hasHttpOnlyCookie ) { return APIError.create('session_required').write(res); } next(); diff --git a/src/gui/src/UI/UIWindowChangeUsername.js b/src/gui/src/UI/UIWindowChangeUsername.js index 557c31b93..c94f10f46 100644 --- a/src/gui/src/UI/UIWindowChangeUsername.js +++ b/src/gui/src/UI/UIWindowChangeUsername.js @@ -161,7 +161,7 @@ async function UIWindowChangeUsername (options) { const new_username = $(el_window).find('.new-username').val(); const body = { new_username }; if ( password !== undefined && password !== '' ) body.password = password; - // Do not send Authorization: user-protected endpoints use session cookie (hasHttpPowers) + // Do not send Authorization: user-protected endpoints use session cookie (hasHttpOnlyCookie) return fetch(apiUrl, { method: 'POST', credentials: 'include', From b3b32980dbc6c46d2f078df79998e7265bc7f0de Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:32:41 -0500 Subject: [PATCH 34/39] clean(oidc): remove jwt remap in ESM code --- src/backend/src/services/auth/OIDCService.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/backend/src/services/auth/OIDCService.js b/src/backend/src/services/auth/OIDCService.js index 28d5dee60..c903deba0 100644 --- a/src/backend/src/services/auth/OIDCService.js +++ b/src/backend/src/services/auth/OIDCService.js @@ -44,10 +44,6 @@ async function generate_random_username () { * Uses config.oidc.providers only; no environment variables. */ export class OIDCService extends BaseService { - static MODULES = { - jwt, - }; - #googleDiscovery; async _init () { @@ -135,14 +131,14 @@ export class OIDCService extends BaseService { * Sign state payload for CSRF protection (short-lived JWT). */ signState (payload) { - return this.modules.jwt.sign(payload, + return jwt.sign(payload, this.global_config.jwt_secret, { expiresIn: STATE_EXPIRY_SEC }); } verifyState (token) { try { - return this.modules.jwt.verify(token, this.global_config.jwt_secret); + return jwt.verify(token, this.global_config.jwt_secret); } catch ( e ) { return null; } From 4e01608e4ab579ccdc2058386320eb28ea0704f6 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:02:33 -0500 Subject: [PATCH 35/39] dev: remove guarded debug log --- src/backend/src/middleware/configurable_auth.js | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/backend/src/middleware/configurable_auth.js b/src/backend/src/middleware/configurable_auth.js index f0a6da750..c2e854318 100644 --- a/src/backend/src/middleware/configurable_auth.js +++ b/src/backend/src/middleware/configurable_auth.js @@ -117,19 +117,6 @@ const configurable_auth = options => async (req, res, next) => { const services = context.get('services'); const svc_auth = services.get('auth'); - // Debug: log token source and decoded type before creating Actor (for session_required / hasHttpOnlyCookie debugging) - if ( process.env.DEBUG ) { - let decodedForLog; - try { - decodedForLog = jwt.decode(token); - } catch ( _ ) { /* ignore */ } - console.log('decodedForLog?', decodedForLog); - const tokenType = decodedForLog && decodedForLog.t != null ? decodedForLog.t : '(no type or invalid jwt)'; - const tokenPreview = typeof token === 'string' && token.length > 20 - ? `${token.slice(0, 12)}...${token.slice(-8)}` - : '(short)'; - } - let actor; try { actor = await svc_auth.authenticate_from_token(token); From 0112f097db5d6f03fbd916234ac0a314f27e7522 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:18:48 -0500 Subject: [PATCH 36/39] style(oidc): if instead of return with ternary expression --- extensions/whoami/routes.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/extensions/whoami/routes.js b/extensions/whoami/routes.js index ad9806677..391904595 100644 --- a/extensions/whoami/routes.js +++ b/extensions/whoami/routes.js @@ -96,9 +96,12 @@ extension.get('/whoami', { subdomain: 'api' }, async (req, res, next) => { const providers = await svc_oidc.getEnabledProviderIds(); const origin = (svc_oidc.global_config?.origin || '').replace(/\/$/, ''); const provider = providers && providers[0]; - return provider - ? { oidc_revalidate_url: `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_id=${req.user.id}` } - : {}; + if ( provider ) { + return { + oidc_revalidate_url: `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_id=${req.user.id}`, + }; + } + return {}; } catch ( _e ) { return {}; } From 1be3eca3353d6a3a9bb2f472134d8e8d8ece964c Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:00:01 -0500 Subject: [PATCH 37/39] fix: rate limits for oidc too extreme --- src/backend/src/routers/auth/oidc.js | 12 ++++++------ .../abuse-prevention/EdgeRateLimitService.js | 4 ++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/backend/src/routers/auth/oidc.js b/src/backend/src/routers/auth/oidc.js index 511cb523a..2e1fca2dc 100644 --- a/src/backend/src/routers/auth/oidc.js +++ b/src/backend/src/routers/auth/oidc.js @@ -17,10 +17,10 @@ * along with this program. If not, see . */ import express from 'express'; -const router = express.Router(); -import config from '../../config.js'; import jwt from 'jsonwebtoken'; +import config from '../../config.js'; import { get_user, subdomain } from '../../helpers.js'; +const router = express.Router(); const REVALIDATION_COOKIE_NAME = 'puter_revalidation'; const REVALIDATION_EXPIRY_SEC = 300; // 5 minutes @@ -89,7 +89,7 @@ router.get('/auth/oidc/:provider/start', async (req, res) => { return res.status(404).end(); } const svc_edgeRateLimit = req.services.get('edge-rate-limit'); - if ( ! svc_edgeRateLimit.check('login') ) { + if ( ! svc_edgeRateLimit.check('oidc-general') ) { return res.status(429).send('Too many requests.'); } const provider = req.params.provider; @@ -128,7 +128,7 @@ router.get('/auth/oidc/callback/login', async (req, res) => { return res.status(404).end(); } const svc_edgeRateLimit = req.services.get('edge-rate-limit'); - if ( ! svc_edgeRateLimit.check('login') ) { + if ( ! svc_edgeRateLimit.check('oidc-general') ) { return res.status(429).send('Too many requests.'); } const svc_oidc = req.services.get('oidc'); @@ -161,7 +161,7 @@ router.get('/auth/oidc/callback/signup', async (req, res) => { return res.status(404).end(); } const svc_edgeRateLimit = req.services.get('edge-rate-limit'); - if ( ! svc_edgeRateLimit.check('login') ) { + if ( ! svc_edgeRateLimit.check('oidc-general') ) { return res.status(429).send('Too many requests.'); } const svc_oidc = req.services.get('oidc'); @@ -196,7 +196,7 @@ router.get('/auth/oidc/callback/revalidate', async (req, res) => { return res.status(404).end(); } const svc_edgeRateLimit = req.services.get('edge-rate-limit'); - if ( ! svc_edgeRateLimit.check('login') ) { + if ( ! svc_edgeRateLimit.check('oidc-general') ) { return res.status(429).send('Too many requests.'); } const svc_oidc = req.services.get('oidc'); diff --git a/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js b/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js index eae7e547f..70384454c 100644 --- a/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js +++ b/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js @@ -44,6 +44,10 @@ class EdgeRateLimitService extends BaseService { */ _construct () { this.scopes = { + 'oidc-general': { + limit: 100, + window: 15 * MINUTE, + }, 'login': { limit: 10, window: 15 * MINUTE, From 0cca9d5535b92dba2303ff68cd239ee60be4f322 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Thu, 19 Feb 2026 16:08:48 -0800 Subject: [PATCH 38/39] fix : import --- src/backend/src/helpers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/src/helpers.js b/src/backend/src/helpers.js index 745218610..73e793694 100644 --- a/src/backend/src/helpers.js +++ b/src/backend/src/helpers.js @@ -34,7 +34,7 @@ import { Context } from './util/context.js'; import { ManagedError } from './util/errorutil.js'; import { kv } from './util/kvSingleton.js'; import { spanify } from './util/otelutil.js'; -import { generate_identifier } from './util/identifier'; +import { generate_identifier } from './util/identifier.js'; export * from './validation.js'; From ef0a665a5fbc07c380f622ef78f8c438fb3a12b0 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:25:24 -0500 Subject: [PATCH 39/39] fix(oidc): add code lost due to editing a `.js` This code was previously lost because I edited `outcomeutil.js` instead of `outcomeutil.ts`. We're not building into a `dist/` directory and `tsc` has a most peculiar lack of generating a comment at the top of output files stating something like "// GENERATED - DO NOT EDIT" as I've seen from every other code generator or transpiler I've worked with. This caused the bug with duplicate confirmed emails and "account not found" during testing on the staging server. --- src/backend/src/util/outcomeutil.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/backend/src/util/outcomeutil.ts b/src/backend/src/util/outcomeutil.ts index cdc838a82..654f151a2 100644 --- a/src/backend/src/util/outcomeutil.ts +++ b/src/backend/src/util/outcomeutil.ts @@ -45,6 +45,10 @@ export class OutcomeObject { this.messages.push({ text, fields }); } + get succeeded () { + return this.ended && !this.failed; + } + /** * Records a failure message. * Returns the outcome object for chaining with a return statement.