From 5faed55076b36d6974c7a2792fa72f13531d94a7 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 22 Jul 2026 21:12:38 -0700 Subject: [PATCH] fix: autoclaim app when making a subdomain (#3426) --- .../controllers/auth/AuthController.test.ts | 42 +- .../controllers/auth/AuthController.ts | 112 +++--- src/backend/drivers/apps/AppDriver.test.ts | 31 ++ src/backend/services/auth/AuthService.test.ts | 68 ++++ src/backend/services/auth/AuthService.ts | 370 ++++++++++-------- src/backend/stores/app/AppStore.js | 151 +++---- 6 files changed, 483 insertions(+), 291 deletions(-) diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index d4e8d6abc..de8473112 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -1724,9 +1724,49 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => { ); const body = res.body as { token: string; app_uid: string }; expect(body.app_uid).toMatch(/^app-/); - // A bootstrap app row was created for that origin. + // A bootstrap app row was created for that origin. External + // origins have no hosted subdomain, so no owner is stamped. const bootstrapped = await server.stores.app.getByUid(body.app_uid); expect(bootstrapped).toBeTruthy(); + expect(bootstrapped?.owner_user_id ?? null).toBeNull(); + }); + + it('stamps the hosted-subdomain owner as the bootstrap app creator', async () => { + // Test config inherits `static_hosting_domain: site.puter.localhost` + // from config.default.json. The subdomain belongs to a different + // user than the visitor minting the token — the app must be owned + // by the site owner, not the visitor. + const ownerName = `so_${uuidv4().slice(0, 8)}`; + await controller.handleSignup( + makeReq({ + username: ownerName, + email: `${ownerName}@test.local`, + password: 'correct-horse-battery', + }), + makeRes(), + ); + const owner = await server.stores.user.getByUsername(ownerName); + expect(owner!.id).not.toBe(user.id); + const subdomain = `sd-${uuidv4().slice(0, 8)}`; + await server.stores.subdomain.create({ + userId: owner!.id, + subdomain, + }); + + const res = makeRes(); + await inCtx(actor, () => + controller.handleGetUserAppToken( + makeReq( + { origin: `https://${subdomain}.site.puter.localhost` }, + { actor }, + ), + res, + ), + ); + const body = res.body as { token: string; app_uid: string }; + const bootstrapped = await server.stores.app.getByUid(body.app_uid); + expect(bootstrapped).toBeTruthy(); + expect(bootstrapped?.owner_user_id).toBe(owner!.id); }); }); diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 5f1ededd4..6a78041ca 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -3,18 +3,19 @@ * * 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. + * 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. + * 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 . + * along with this program. If not, see + * [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). */ import bcrypt from 'bcrypt'; @@ -107,14 +108,13 @@ const RESERVED_USERNAMES = new Set([ * Auth controller — login/logout, permission grants/revokes, session * management, OTP, and permission checks. * - * Routes are declared via decorators (@Get/@Post on each handler). The - * five `/user-protected/*` and `/user-protected/delete-own-user` routes - * also need a per-instance `createUserProtectedGate(...)` middleware - * built from `this.config / this.stores / this.services`, which can't - * live in a static decorator literal — those are wired imperatively in - * the `registerRoutes` override below. The override also re-runs the - * default decorator-walker logic so the rest of the routes register - * normally. + * Routes are declared via decorators (@Get/@Post on each handler). The five + * `/user-protected/*` and `/user-protected/delete-own-user` routes also need a + * per-instance `createUserProtectedGate(...)` middleware built from + * `this.config / this.stores / this.services`, which can't live in a static + * decorator literal — those are wired imperatively in the `registerRoutes` + * override below. The override also re-runs the default decorator-walker logic + * so the rest of the routes register normally. */ @Controller('') export class AuthController extends PuterController { @@ -1140,10 +1140,10 @@ export class AuthController extends PuterController { * single greppable line tying that id to the real reason (so support can * look it up in CloudWatch with the id the user quotes), stores the same * record in KV under `sms-send-error:` for a week (the admin - * abuse page looks it up there without needing log access), and returns - * the `HttpError` with the id attached as `error_id` for the GUI to - * surface. The phone number is deliberately omitted from the log line and - * the KV record (PII); the user + country are enough to correlate. + * abuse page looks it up there without needing log access), and returns the + * `HttpError` with the id attached as `error_id` for the GUI to surface. + * The phone number is deliberately omitted from the log line and the KV + * record (PII); the user + country are enough to correlate. */ private async smsSendError( statusCode: number, @@ -1650,11 +1650,11 @@ export class AuthController extends PuterController { /** * Start card verification for the calling user. Pure mechanism: the - * endpoint emits `puter.card-verification.setup` and a payments - * extension fills in the client credentials — the OSS backend holds - * no provider knowledge or config. Phone verification (when required) - * must be completed first; the ordering is enforced here so a client - * can't skip the cheaper gate. + * endpoint emits `puter.card-verification.setup` and a payments extension + * fills in the client credentials — the OSS backend holds no provider + * knowledge or config. Phone verification (when required) must be completed + * first; the ordering is enforced here so a client can't skip the cheaper + * gate. */ @Post('/card-verification/setup', { subdomain: ['api', ''], @@ -1765,9 +1765,9 @@ export class AuthController extends PuterController { } /** - * Complete card verification. The client confirms the setup intent with - * the payment provider directly, then posts the resulting id here; the - * payments extension checks it (and applies its own abuse limits) via + * Complete card verification. The client confirms the setup intent with the + * payment provider directly, then posts the resulting id here; the payments + * extension checks it (and applies its own abuse limits) via * `puter.card-verification.confirm`. On success the gate clears exactly * like `/confirm-phone` clears the phone gate. */ @@ -3052,7 +3052,13 @@ export class AuthController extends PuterController { let app = await this.stores.app.getByUid(app_uid); if (!app && resolvedFromOrigin) { - app = await this.stores.app.createFromOrigin(app_uid, origin); + // Hosted-subdomain origins get the site owner stamped as the + // app's creator at bootstrap; external origins stay unowned. + const ownerUserId = + await this.services.auth.subdomainOwnerIdFromOrigin(origin); + app = await this.stores.app.createFromOrigin(app_uid, origin, { + ownerUserId, + }); } if (!app) { throw new HttpError(404, `App ${app_uid} does not exist`, { @@ -4046,10 +4052,10 @@ export class AuthController extends PuterController { } /** - * Config-blocklist + extension-driven email validation. - * Config blocklist (suffix match on cleaned email) blocks first; then - * the `email.validate` event lets extensions (abuse) reject. - * Throws HttpError(400) on rejection. + * Config-blocklist + extension-driven email validation. Config blocklist + * (suffix match on cleaned email) blocks first; then the `email.validate` + * event lets extensions (abuse) reject. Throws HttpError(400) on + * rejection. */ async #validateEmail(email: string): Promise { if ( @@ -4094,9 +4100,9 @@ export class AuthController extends PuterController { /** * Per-IP enumeration clamp on `auth_id`-bearing auth requests. Separate - * from the route-level rate limit so a tighter ceiling applies only to - * the path that takes a uuid hint from the body — the normal login - * path stays at its more generous limit. + * from the route-level rate limit so a tighter ceiling applies only to the + * path that takes a uuid hint from the body — the normal login path stays + * at its more generous limit. */ async #checkAuthIdRateLimit(req: Request): Promise { const ip = req.ip || req.socket?.remoteAddress || 'unknown'; @@ -4114,12 +4120,11 @@ export class AuthController extends PuterController { } /** - * Extract the `auth_id` claim from a client-supplied reauth_token. - * Returns null when no token was supplied. Throws on invalid/expired - * tokens. The reauth_token is a server-signed JWT minted by the - * authProbe at 401 time — accepting only the signed envelope (vs. a - * raw UUID) means a leaked auth_id alone can't attach a session to - * an existing account. + * Extract the `auth_id` claim from a client-supplied reauth_token. Returns + * null when no token was supplied. Throws on invalid/expired tokens. The + * reauth_token is a server-signed JWT minted by the authProbe at 401 time — + * accepting only the signed envelope (vs. a raw UUID) means a leaked + * auth_id alone can't attach a session to an existing account. */ #extractAuthIdFromReauthToken(suppliedToken: unknown): string | null { if (suppliedToken === undefined || suppliedToken === null) return null; @@ -4133,18 +4138,17 @@ export class AuthController extends PuterController { } /** - * Enforce a verified `auth_id` against the user the credential flow - * has resolved. Caller has already extracted `auth_id` from the - * server-signed reauth_token (or from an OTP-flow JWT). When the GUI - * is forced through reauth, the 401 response embeds the reauth_token; - * the client echoes it back so we can confirm the second login lands - * on the same user row — critical for temp users, where a fresh - * signup would otherwise mint a new account and strand their files. + * Enforce a verified `auth_id` against the user the credential flow has + * resolved. Caller has already extracted `auth_id` from the server-signed + * reauth_token (or from an OTP-flow JWT). When the GUI is forced through + * reauth, the 401 response embeds the reauth_token; the client echoes it + * back so we can confirm the second login lands on the same user row — + * critical for temp users, where a fresh signup would otherwise mint a new + * account and strand their files. * - * No `authId` supplied → no-op (normal login). Unknown `authId` → - * 404 (mirrors username-not-found, avoids being an enumeration - * oracle). Mismatch against the resolved user → 409 - * (`auth_id_mismatch`). + * No `authId` supplied → no-op (normal login). Unknown `authId` → 404 + * (mirrors username-not-found, avoids being an enumeration oracle). + * Mismatch against the resolved user → 409 (`auth_id_mismatch`). */ async #enforceAuthIdMatch( req: Request, diff --git a/src/backend/drivers/apps/AppDriver.test.ts b/src/backend/drivers/apps/AppDriver.test.ts index db9c2aee3..f331247eb 100644 --- a/src/backend/drivers/apps/AppDriver.test.ts +++ b/src/backend/drivers/apps/AppDriver.test.ts @@ -1258,6 +1258,37 @@ describe('AppDriver hosted-subdomain ownership check', () => { } }); + it('create merges into an owner-stamped bootstrap stub for an owned subdomain', async () => { + // The get-user-app-token bootstrap path stamps the subdomain owner + // on the stub at mint time — the owner's later create must still + // absorb the stub (claimOwnership is skipped, merge proceeds). + const { actor, userId } = await makeUser(); + const sub = uniqueName('ownedstub'); + await server.stores.subdomain.create({ userId, subdomain: sub }); + const stubUid = `app-${uuidv4()}`; + await server.stores.app.createFromOrigin( + stubUid, + `https://${sub}.site.puter.localhost`, + { ownerUserId: userId }, + ); + + const name = uniqueName('owned-create'); + const result = await withActor(actor, () => + driver.create({ + object: { + name, + title: 'Owned stub', + index_url: hostedUrl(sub), + }, + }), + ); + + expect(result.uid).toBe(stubUid); + expect(result.name).toBe(name); + const stored = await server.stores.app.getByUid(stubUid); + expect(stored?.owner_user_id).toBe(userId); + }); + it('rejects a hosted index_url whose subdomain does not exist anywhere', async () => { const { actor } = await makeUser(); await expect( diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index 824334b33..5a8f68d9a 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -1348,6 +1348,74 @@ describe('AuthService (integration)', () => { }); }); + describe('subdomainOwnerIdFromOrigin', () => { + // Test servers inherit the four hosting domains (production: + // puter.site / puter.host / puter.app / puter.dev) from + // config.default.json. + it.each([ + 'site.puter.localhost', + 'host.puter.localhost', + 'app.puter.localhost', + 'dev.puter.localhost', + ])( + 'returns the subdomain owner for an origin under %s', + async (hostingDomain) => { + const user = await makeUser(); + const subdomain = `own-${Math.random().toString(36).slice(2, 10)}`; + await server.stores.subdomain.create({ + userId: user.id, + subdomain, + }); + await expect( + authService.subdomainOwnerIdFromOrigin( + `https://${subdomain}.${hostingDomain}`, + ), + ).resolves.toBe(user.id); + }, + ); + + it('matches a hosted origin that carries an explicit port', async () => { + const user = await makeUser(); + const subdomain = `own-${Math.random().toString(36).slice(2, 10)}`; + await server.stores.subdomain.create({ + userId: user.id, + subdomain, + }); + await expect( + authService.subdomainOwnerIdFromOrigin( + `http://${subdomain}.site.puter.localhost:4100`, + ), + ).resolves.toBe(user.id); + }); + + it('returns null for an origin outside the hosting domains', async () => { + await expect( + authService.subdomainOwnerIdFromOrigin( + `https://external-${uuidv4()}.example.com`, + ), + ).resolves.toBeNull(); + }); + + it('returns null for an unregistered subdomain and for the apex host', async () => { + await expect( + authService.subdomainOwnerIdFromOrigin( + `https://ghost-${uuidv4().slice(0, 8)}.site.puter.localhost`, + ), + ).resolves.toBeNull(); + await expect( + authService.subdomainOwnerIdFromOrigin( + 'https://site.puter.localhost', + ), + ).resolves.toBeNull(); + }); + + it('returns null for an unparseable origin', async () => { + await expect( + authService.subdomainOwnerIdFromOrigin('not-a-url'), + ).resolves.toBeNull(); + }); + }); + describe('app origin blocklist enforcement', () => { // The blocklist service caches with a TTL, so seed the row then // invalidate the in-memory snapshot to force a reload for the test. diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 6b4d784fe..1231d4c83 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -3,18 +3,19 @@ * * 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. + * 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. + * 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 . + * along with this program. If not, see + * [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). */ import { v4 as uuidv4, v5 as uuidv5 } from 'uuid'; @@ -52,10 +53,10 @@ export interface AuthResult { reauth?: { reason: ReauthReason; auth_id?: string }; invalid?: true; /** - * The token authenticated, but its app is on the origin blocklist. The - * auth probe surfaces this as `req.appBlocked`; gates translate it to a - * 403 `app_blocked`. Distinct from `invalid` so the client sees a clear - * "app blocked" error rather than a generic auth failure. + * The token authenticated, but its app is on the origin blocklist. The auth + * probe surfaces this as `req.appBlocked`; gates translate it to a 403 + * `app_blocked`. Distinct from `invalid` so the client sees a clear "app + * blocked" error rather than a generic auth failure. */ blocked?: { reason?: string }; } @@ -63,10 +64,10 @@ export interface AuthResult { /** * Authentication service. * - * Scope is currently narrow — just `authenticateFromToken`, the one method - * the auth-probe middleware needs. Session creation, logout, token - * rotation, 2FA, and the rest of the auth surface will land when the auth - * controller is wired up (it will own mint / rotate / revoke). + * Scope is currently narrow — just `authenticateFromToken`, the one method the + * auth-probe middleware needs. Session creation, logout, token rotation, 2FA, + * and the rest of the auth surface will land when the auth controller is wired + * up (it will own mint / rotate / revoke). */ export class AuthService extends PuterService { declare protected services: LayerInstances; @@ -101,16 +102,16 @@ export class AuthService extends PuterService { /** * Mint a short-lived, server-signed JWT that proves the bearer was - * previously identified as `authId` by a real session (the one that - * just rejected with reauth-required). The 401 response embeds this - * token; the GUI echoes it back on /login or /signup so the controller - * can re-attach the new session to the same user row. + * previously identified as `authId` by a real session (the one that just + * rejected with reauth-required). The 401 response embeds this token; the + * GUI echoes it back on /login or /signup so the controller can re-attach + * the new session to the same user row. * - * Signing here — rather than letting the client present the raw - * `auth_id` UUID — means a leaked UUID alone is not enough to attach - * a session to an existing temp account; the attacker would also - * have to have intercepted a live 401 from that user. The token's - * 10-minute TTL bounds that intercept window. + * Signing here — rather than letting the client present the raw `auth_id` + * UUID — means a leaked UUID alone is not enough to attach a session to an + * existing temp account; the attacker would also have to have intercepted a + * live 401 from that user. The token's 10-minute TTL bounds that intercept + * window. */ signReauthToken(authId: string): string { return this.services.token.sign( @@ -121,8 +122,8 @@ export class AuthService extends PuterService { } /** - * Verify a reauth token and return its `auth_id` claim. Throws an - * HttpError on signature failure, expiry, or wrong purpose. + * Verify a reauth token and return its `auth_id` claim. Throws an HttpError + * on signature failure, expiry, or wrong purpose. */ verifyReauthToken(token: string): { authId: string } { let decoded: { auth_id?: string; purpose?: string }; @@ -205,8 +206,8 @@ export class AuthService extends PuterService { /** * Create a session and sign a session JWT + GUI JWT for the user. * - * `meta` is enriched with request metadata (IP, user-agent, etc.) - * when a request context is available. + * `meta` is enriched with request metadata (IP, user-agent, etc.) when a + * request context is available. */ async createSessionToken( user: UserRow, @@ -262,7 +263,10 @@ export class AuthService extends PuterService { ); } - /** Shared signer for session/gui tokens — keeps the v2 claim shape consistent. */ + /** + * Shared signer for session/gui tokens — keeps the v2 claim shape + * consistent. + */ #signSessionTypeToken( type: 'session' | 'gui', user: UserRow, @@ -287,14 +291,13 @@ export class AuthService extends PuterService { } /** - * Worker variant of `createSessionToken` for user-scoped workers - * (not bound to any specific app). Idempotent on - * (user_id, worker_name) via the `kind='worker'` partial unique - * index — redeploying the same worker reuses the row and returns - * the same stable token. Expires after `WORKER_WINDOW_SECONDS` - * (effectively infinite). The emitted JWT carries `worker: true` - * and `worker_name` so downstream code can tell a worker session - * from a user-driven one without a DB round-trip. + * Worker variant of `createSessionToken` for user-scoped workers (not bound + * to any specific app). Idempotent on (user_id, worker_name) via the + * `kind='worker'` partial unique index — redeploying the same worker reuses + * the row and returns the same stable token. Expires after + * `WORKER_WINDOW_SECONDS` (effectively infinite). The emitted JWT carries + * `worker: true` and `worker_name` so downstream code can tell a worker + * session from a user-driven one without a DB round-trip. */ async createWorkerSessionToken( user: UserRow, @@ -344,13 +347,12 @@ export class AuthService extends PuterService { } /** - * Worker variant of `getUserAppToken` for app-scoped workers. - * Idempotent on (user_id, app_uid, worker_name) via the - * `kind='worker'` partial unique index — the same app can host - * many workers distinguished by name, each getting its own - * stable long-lived token. Coexists with an interactive - * `kind='app'` session for the same (user, app) because the - * uniqueness keys don't overlap. + * Worker variant of `getUserAppToken` for app-scoped workers. Idempotent on + * (user_id, app_uid, worker_name) via the `kind='worker'` partial unique + * index — the same app can host many workers distinguished by name, each + * getting its own stable long-lived token. Coexists with an interactive + * `kind='app'` session for the same (user, app) because the uniqueness keys + * don't overlap. */ async createWorkerAppToken( actor: Actor, @@ -397,8 +399,8 @@ export class AuthService extends PuterService { /** * Scope app token delegation by actor kind. An app-under-user or - * access-token actor is bound to a single app and may only mint a token - * for that same app; only a root user session may request a token for an + * access-token actor is bound to a single app and may only mint a token for + * that same app; only a root user session may request a token for an * arbitrary app (the GUI's app-launch delegation). */ #assertAppDelegationAllowed(actor: Actor, appUid: string): void { @@ -414,8 +416,8 @@ export class AuthService extends PuterService { /** * Convert a jsonwebtoken-style `expiresIn` (seconds, or `'1h'`/`'30d'`) * into an absolute unix-seconds timestamp for the session row. Returns - * `null` when no expiry is requested (caller passed `undefined`). - * Mirrors `jsonwebtoken`'s allowed unit suffixes (s/m/h/d/w/y). + * `null` when no expiry is requested (caller passed `undefined`). Mirrors + * `jsonwebtoken`'s allowed unit suffixes (s/m/h/d/w/y). */ #hardExpiryFromExpiresIn( expiresIn: string | number | undefined, @@ -473,11 +475,11 @@ export class AuthService extends PuterService { } /** - * List sessions surfaced to the manage-sessions UI. Excludes `asset` - * rows (per-cookie children of `web` rows, revoked transitively via - * cascade — surfacing them as standalone entries would be confusing). - * App rows are joined to the apps table so the UI can render the - * authorizing app's title and icon without a second round trip. + * List sessions surfaced to the manage-sessions UI. Excludes `asset` rows + * (per-cookie children of `web` rows, revoked transitively via cascade — + * surfacing them as standalone entries would be confusing). App rows are + * joined to the apps table so the UI can render the authorizing app's title + * and icon without a second round trip. */ async listSessions(actor: Actor): Promise>> { if (!actor.user?.id) return []; @@ -557,21 +559,20 @@ export class AuthService extends PuterService { } /** - * Revoke a session by uuid, cascading to any rows whose - * `parent_session_id` points at it. Used by the manage-sessions UI - * and by `removeSessionByToken` — semantics are identical. + * Revoke a session by uuid, cascading to any rows whose `parent_session_id` + * points at it. Used by the manage-sessions UI and by + * `removeSessionByToken` — semantics are identical. */ async revokeSession(uuid: string): Promise { await this.stores.session.revokeCascade(uuid); } /** - * Rename a session's user-visible label. Throws 404 when the row - * doesn't exist or belongs to another user — ownership is enforced - * inside `SessionStore.setLabel` via the (uuid, user_id) WHERE - * clause, so the 404 vs 403 distinction is collapsed (a user can't - * tell from this endpoint whether a uuid exists under another - * account). + * Rename a session's user-visible label. Throws 404 when the row doesn't + * exist or belongs to another user — ownership is enforced inside + * `SessionStore.setLabel` via the (uuid, user_id) WHERE clause, so the 404 + * vs 403 distinction is collapsed (a user can't tell from this endpoint + * whether a uuid exists under another account). */ async setSessionLabel( actor: Actor, @@ -598,17 +599,17 @@ export class AuthService extends PuterService { } /** - * Admin-driven cascade: revoke EVERY session row for the given user - * (web, app, access_token, asset, worker). No actor context — this - * is the "suspension / forced sign-out" path, where workers - * deliberately go too (a suspended user shouldn't keep long-lived - * worker credentials calling back into the backend). Distinct from - * `revokeAllSessions` which is the user-driven UI flow and exempts - * workers + standalone access tokens by design. + * Admin-driven cascade: revoke EVERY session row for the given user (web, + * app, access_token, asset, worker). No actor context — this is the + * "suspension / forced sign-out" path, where workers deliberately go too (a + * suspended user shouldn't keep long-lived worker credentials calling back + * into the backend). Distinct from `revokeAllSessions` which is the + * user-driven UI flow and exempts workers + standalone access tokens by + * design. * - * Iterates each top-level row through `revokeCascade` so derived - * rows (asset under web, app-issued access tokens under their app - * session) follow via the parent_session_id link. + * Iterates each top-level row through `revokeCascade` so derived rows + * (asset under web, app-issued access tokens under their app session) + * follow via the parent_session_id link. */ async revokeAllSessionsForUserId(userId: number): Promise { if (!userId) return; @@ -619,12 +620,12 @@ export class AuthService extends PuterService { } /** - * Password-reset cascade: revoke every interactive (web/app) session - * for the user, so a hijacked session doesn't outlive a password - * reset. No actor context — the recovery flow has no authenticated - * caller. Leaves workers and standalone access tokens alone: those - * are managed credentials rather than sign-ins, and a routine - * forgot-password reset shouldn't break deployments. + * Password-reset cascade: revoke every interactive (web/app) session for + * the user, so a hijacked session doesn't outlive a password reset. No + * actor context — the recovery flow has no authenticated caller. Leaves + * workers and standalone access tokens alone: those are managed credentials + * rather than sign-ins, and a routine forgot-password reset shouldn't break + * deployments. */ async revokeInteractiveSessionsForUserId(userId: number): Promise { if (!userId) return; @@ -843,9 +844,9 @@ export class AuthService extends PuterService { } /** - * Per-`auth_id` rate limit for migrate-token. Keyed on the stable - * v2 identity so an attacker rotating IPs but holding one user's - * v1 token still hits a ceiling. + * Per-`auth_id` rate limit for migrate-token. Keyed on the stable v2 + * identity so an attacker rotating IPs but holding one user's v1 token + * still hits a ceiling. */ async #enforceMigrateAuthIdLimit(auth_id: string): Promise { // 20 migrations per 15min per identity matches the per-IP @@ -875,15 +876,16 @@ export class AuthService extends PuterService { * surfaces resolve to the same app row). * * Lookup order: - * 1. **Canonical DB match.** If any app in the DB has an `index_url` - * that normalizes to this origin (across every configured hosting - * variant — `puter.site`, `puter.app`, etc.), return that app's - * real UID. Required for private apps: their `/auth/get-user-app-token` - * tokens must reference the real app row so the app-under-user - * verification path can load them. - * 2. **UUIDv5 deterministic fallback.** Origins that don't match any - * app (third-party sites, apps not yet in the DB) get a deterministic - * namespaced UUID + * + * 1. **Canonical DB match.** If any app in the DB has an `index_url` that + * normalizes to this origin (across every configured hosting variant — + * `puter.site`, `puter.app`, etc.), return that app's real UID. Required + * for private apps: their `/auth/get-user-app-token` tokens must + * reference the real app row so the app-under-user verification path can + * load them. + * 2. **UUIDv5 deterministic fallback.** Origins that don't match any app + * (third-party sites, apps not yet in the DB) get a deterministic + * namespaced UUID */ async appUidFromOrigin(origin: string): Promise { const parsed = this.#originFromUrl(origin); @@ -923,10 +925,41 @@ export class AuthService extends PuterService { } /** - * Read `app_origin_aliases` from config and return normalized groups — - * each group is a deduped list of lowercased, trimmed host strings. - * Malformed entries are skipped silently so a bad config row doesn't - * brick UID resolution for everyone else. + * Resolve the user who owns the hosted subdomain `origin` points at. + * Returns the `subdomains` row's `user_id` when the origin's host sits + * under a configured hosting domain (`static_hosting_domain(_alt)` / + * `private_app_hosting_domain(_alt)`) and the subdomain is registered; null + * for external origins, apex hosting-domain hosts, and unknown subdomains. + * Used to stamp the site owner as the creator of origin-bootstrap app + * rows. + */ + async subdomainOwnerIdFromOrigin(origin: string): Promise { + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + return null; + } + const subdomain = this.#hostedSubdomainForHost( + parsed.host.toLowerCase(), + parsed.hostname.toLowerCase(), + this.#getHostingDomains(), + ); + if (!subdomain) return null; + const row = await this.stores.subdomain.getBySubdomain(subdomain); + const ownerId = row?.user_id; + return typeof ownerId === 'number' && + Number.isInteger(ownerId) && + ownerId > 0 + ? ownerId + : null; + } + + /** + * Read `app_origin_aliases` from config and return normalized groups — each + * group is a deduped list of lowercased, trimmed host strings. Malformed + * entries are skipped silently so a bad config row doesn't brick UID + * resolution for everyone else. */ #getOriginAliasGroups(): string[][] { const config = this.config as { app_origin_aliases?: unknown }; @@ -990,6 +1023,59 @@ export class AuthService extends PuterService { return parsed.toString(); } + /** + * Configured hosting domains (`static_hosting_domain(_alt)` + + * `private_app_hosting_domain(_alt)`), normalized, each in both raw + * (possibly `host:port`) and port-stripped form. + */ + #getHostingDomains(): string[] { + const config = this.config as { + static_hosting_domain?: string; + static_hosting_domain_alt?: string; + private_app_hosting_domain?: string; + private_app_hosting_domain_alt?: string; + }; + + const normalizeDomainValue = (v: unknown): string | null => { + if (typeof v !== 'string') return null; + const trimmed = v.trim().toLowerCase().replace(/^\./, ''); + return trimmed || null; + }; + const stripPort = (v: string): string => v.split(':')[0] || v; + + const raw = [ + normalizeDomainValue(config.static_hosting_domain), + normalizeDomainValue(config.static_hosting_domain_alt), + normalizeDomainValue(config.private_app_hosting_domain), + normalizeDomainValue(config.private_app_hosting_domain_alt), + ].filter((d): d is string => !!d); + return [...new Set([...raw, ...raw.map(stripPort)])]; + } + + /** + * Extract the subdomain label under the longest matching hosting domain — + * longest-first avoids matching `puter.app` before `foo.puter.app`. Null + * when the host IS a hosting domain or sits under none of them. + */ + #hostedSubdomainForHost( + hostRaw: string, + hostStripped: string, + hostingDomains: string[], + ): string | null { + const sorted = [...hostingDomains].sort((a, b) => b.length - a.length); + for (const d of sorted) { + const suffix = `.${d}`; + if (hostRaw === d || hostStripped === d) return null; + for (const host of [hostRaw, hostStripped]) { + if (host.endsWith(suffix)) { + const prefix = host.slice(0, host.length - suffix.length); + return prefix.split('.')[0] || null; + } + } + } + return null; + } + /** * Find the real app row whose `index_url` canonically matches `origin`. * @@ -1008,62 +1094,17 @@ export class AuthService extends PuterService { return null; } - const config = this.config as { - static_hosting_domain?: string; - static_hosting_domain_alt?: string; - private_app_hosting_domain?: string; - private_app_hosting_domain_alt?: string; - protocol?: string; - }; - - const normalizeDomainValue = (v: unknown): string | null => { - if (typeof v !== 'string') return null; - const trimmed = v.trim().toLowerCase().replace(/^\./, ''); - return trimmed || null; - }; - const stripPort = (v: string): string => v.split(':')[0] || v; - - const hostingDomainsRaw = [ - normalizeDomainValue(config.static_hosting_domain), - normalizeDomainValue(config.static_hosting_domain_alt), - normalizeDomainValue(config.private_app_hosting_domain), - normalizeDomainValue(config.private_app_hosting_domain_alt), - ].filter((d): d is string => !!d); - const hostingDomainsStripped = hostingDomainsRaw.map(stripPort); - const hostingDomains = [ - ...new Set([...hostingDomainsRaw, ...hostingDomainsStripped]), - ]; + const config = this.config as { protocol?: string }; + const hostingDomains = this.#getHostingDomains(); const hostRaw = parsed.host.toLowerCase(); const hostStripped = parsed.hostname.toLowerCase(); - // Extract the subdomain label under the longest matching hosting - // domain — longest-first avoids matching `puter.app` before - // `foo.puter.app`. - let subdomain: string | null = null; - const sortedHostingDomains = [...hostingDomains].sort( - (a, b) => b.length - a.length, + const subdomain = this.#hostedSubdomainForHost( + hostRaw, + hostStripped, + hostingDomains, ); - for (const d of sortedHostingDomains) { - const suffix = `.${d}`; - if (hostRaw === d || hostStripped === d) { - subdomain = null; - break; - } - if (hostRaw.endsWith(suffix)) { - subdomain = hostRaw.slice(0, hostRaw.length - suffix.length); - subdomain = subdomain.split('.')[0] || null; - break; - } - if (hostStripped.endsWith(suffix)) { - subdomain = hostStripped.slice( - 0, - hostStripped.length - suffix.length, - ); - subdomain = subdomain.split('.')[0] || null; - break; - } - } const hostCandidates = new Set([hostRaw, hostStripped]); if (subdomain) { @@ -1150,8 +1191,8 @@ export class AuthService extends PuterService { // replayed against another. `verify*Token` enforces those expectations. /** - * Cookie name that carries the sticky private-asset token. Legacy - * dot-style name kept readable through the v2 deprecation window — + * Cookie name that carries the sticky private-asset token. Legacy dot-style + * name kept readable through the v2 deprecation window — * `resolvePrivateIdentity` still reads it as a fallback. */ getPrivateAssetCookieName(): string { @@ -1163,12 +1204,12 @@ export class AuthService extends PuterService { return 'puter.public.hosted.actor.token'; } - /** v2 cookie name for the sticky private-asset token. */ + /** V2 cookie name for the sticky private-asset token. */ getPrivateAssetCookieNameV2(): string { return 'puter_private_asset_token_v2'; } - /** v2 cookie name for the public hosted-actor token. */ + /** V2 cookie name for the public hosted-actor token. */ getPublicHostedActorCookieNameV2(): string { return 'puter_public_hosted_actor_token_v2'; } @@ -1243,18 +1284,18 @@ export class AuthService extends PuterService { /** * Materialize the `kind='asset'` session row that the cookie's - * `session_uuid` claim points at. Parented to the web session so a - * logout cascade kills every asset cookie minted under it. Both - * fields are `null` only when the caller didn't supply a web session - * at all — the cookie still mints unparented and without an - * `auth_id` claim (matches v1 behavior for access-token-minted - * cookies that aren't tied to an interactive session). + * `session_uuid` claim points at. Parented to the web session so a logout + * cascade kills every asset cookie minted under it. Both fields are `null` + * only when the caller didn't supply a web session at all — the cookie + * still mints unparented and without an `auth_id` claim (matches v1 + * behavior for access-token-minted cookies that aren't tied to an + * interactive session). * - * If the caller DID supply a `webSessionUuid` but the lookup misses - * (row revoked / expired between mint request and this lookup), - * throw — otherwise we'd quietly emit an unparented "ghost" cookie - * that has no revocation hook for 7 days. The extra check piggybacks - * on the lookup we already had to do, so no added perf cost. + * If the caller DID supply a `webSessionUuid` but the lookup misses (row + * revoked / expired between mint request and this lookup), throw — + * otherwise we'd quietly emit an unparented "ghost" cookie that has no + * revocation hook for 7 days. The extra check piggybacks on the lookup we + * already had to do, so no added perf cost. */ async #mintAssetSessionContext( webSessionUuid: string | undefined, @@ -1470,9 +1511,8 @@ export class AuthService extends PuterService { /** * Create an access token with the given permissions. * - * Each permission spec is `[permissionString, extraObject?]`. - * The token is stored in `access_token_permissions` and a JWT is - * returned. + * Each permission spec is `[permissionString, extraObject?]`. The token is + * stored in `access_token_permissions` and a JWT is returned. */ async createAccessToken( actor: Actor, diff --git a/src/backend/stores/app/AppStore.js b/src/backend/stores/app/AppStore.js index f4a0cb2d0..72878ff37 100644 --- a/src/backend/stores/app/AppStore.js +++ b/src/backend/stores/app/AppStore.js @@ -3,18 +3,19 @@ * * 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. + * 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. + * 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 . + * along with this program. If not, see + * [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). */ import { v4 as uuidv4 } from 'uuid'; @@ -26,10 +27,10 @@ import { validateUrl } from '../../util/validation.js'; /** * Persistence + cache for the `apps` table. * - * CRUD over app rows with multi-key caching (uid, name, id). No - * validation or permission logic — those live in AppDriver. Callers - * that need enforcement should go through the driver; callers that - * just need data (internal services) can hit the store directly. + * CRUD over app rows with multi-key caching (uid, name, id). No validation or + * permission logic — those live in AppDriver. Callers that need enforcement + * should go through the driver; callers that just need data (internal services) + * can hit the store directly. */ const CACHE_KEY_PREFIX = 'apps'; @@ -134,10 +135,9 @@ export class AppStore extends PuterStore { /** * Batched lookup by id. Dedupes input ids, reads cache via a pipelined - * MGET, and resolves remaining misses with a single - * `SELECT … WHERE id IN (…)` per chunk. Use this in place of - * `Promise.all(ids.map(getById))` to avoid one connection per row on - * large id sets. + * MGET, and resolves remaining misses with a single `SELECT … WHERE id IN + * (…)` per chunk. Use this in place of `Promise.all(ids.map(getById))` to + * avoid one connection per row on large id sets. * * Missing ids (no DB row) are simply absent from the returned map. */ @@ -218,11 +218,11 @@ export class AppStore extends PuterStore { } /** - * Find the oldest app whose `index_url` matches one of `candidates`. - * Used by the driver to detect duplicate puter-hosted index_url rows - * (origin-bootstrap apps + same-owner duplicates) so they can be - * merged on `create` / `update`. Returns the minimal row shape - * needed by the merge path; falsy when nothing matches. + * Find the oldest app whose `index_url` matches one of `candidates`. Used + * by the driver to detect duplicate puter-hosted index_url rows + * (origin-bootstrap apps + same-owner duplicates) so they can be merged on + * `create` / `update`. Returns the minimal row shape needed by the merge + * path; falsy when nothing matches. */ async findByIndexUrlCandidates(candidates, { excludeAppId } = {}) { if (!Array.isArray(candidates) || candidates.length === 0) return null; @@ -239,10 +239,10 @@ export class AppStore extends PuterStore { } /** - * Set `owner_user_id` on an app row only if it has no current owner. - * Used by the merge path to claim ownership of an origin-bootstrap - * app row (auto-created with no owner) before merging into it. - * Returns true iff the row was modified. + * Set `owner_user_id` on an app row only if it has no current owner. Used + * by the merge path to claim ownership of an origin-bootstrap app row + * (auto-created with no owner) before merging into it. Returns true iff the + * row was modified. */ async claimOwnership(appId, userId) { if (!Number.isInteger(appId) || appId <= 0) return false; @@ -262,15 +262,15 @@ export class AppStore extends PuterStore { } /** - * List apps with optional filters. Returns raw normalised rows — - * the driver is responsible for permission filtering + icon URL - * resolution when serving to clients. + * List apps with optional filters. Returns raw normalised rows — the driver + * is responsible for permission filtering + icon URL resolution when + * serving to clients. * * @param {object} filters * @param {number} [filters.ownerUserId] — only apps owned by this user * @param {number} [filters.appOwner] — only apps created by another app * @param {string} [filters.name] — exact name match - * @param {number} [filters.limit=500] + * @param {number} [filters.limit=500] Default is `500` */ async list(filters = {}) { const where = []; @@ -361,18 +361,17 @@ export class AppStore extends PuterStore { /** * Create a new app row. * - * `fields` is the post-validation column map from the driver. - * `ownerUserId` and `appOwner` come in as a separate arg rather - * than living on `fields` so user-derived patches can never fake - * ownership — the store's READ_ONLY_COLUMNS filter would strip - * them from `fields` anyway; putting them here makes the - * privileged contract obvious at the call site. + * `fields` is the post-validation column map from the driver. `ownerUserId` + * and `appOwner` come in as a separate arg rather than living on `fields` + * so user-derived patches can never fake ownership — the store's + * READ_ONLY_COLUMNS filter would strip them from `fields` anyway; putting + * them here makes the privileged contract obvious at the call site. * * Returns the created app row. */ async create( fields, - /** @type {{ownerUserId?: number, appOwner?: number}} */ + /** @type {{ ownerUserId?: number; appOwner?: number }} */ { ownerUserId, appOwner = null } = {}, ) { if (typeof ownerUserId !== 'number') { @@ -409,14 +408,21 @@ export class AppStore extends PuterStore { } /** - * Create an origin-bootstrap app row for `origin` with the - * deterministic `uid` (UUIDv5 of origin). No owner — the row stays - * unclaimed until a developer registers an app for the same origin - * and AppDriver's merge path takes ownership. Marker shape - * (`name === uid && title === uid` + description prefix) is what + * Create an origin-bootstrap app row for `origin` with the deterministic + * `uid` (UUIDv5 of origin). `ownerUserId` is a privileged arg (same + * contract as `create`) — callers pass the hosted-subdomain owner so the + * site owner is the app's creator from the first minted token. Without one + * the row stays unclaimed until a developer registers an app for the same + * origin and AppDriver's merge path takes ownership. Marker shape (`name + * === uid && title === uid` + description prefix) is what * `#isOriginBootstrapApp` checks. */ - async createFromOrigin(uid, origin) { + async createFromOrigin( + uid, + origin, + /** @type {{ ownerUserId?: number | null }} */ + { ownerUserId = null } = {}, + ) { // Bootstrap apps persist `origin` straight into `index_url`, which is // later loaded as `iframe.src`. Enforce the same http(s) scheme // allow-list as the AppDriver create/update path so a caller-supplied @@ -429,7 +435,10 @@ export class AppStore extends PuterStore { title: uid, description: `App created from origin ${origin}`, index_url: origin, - owner_user_id: null, + owner_user_id: + Number.isInteger(ownerUserId) && ownerUserId > 0 + ? ownerUserId + : null, }; const columns = ['uid', ...Object.keys(fields)]; @@ -517,12 +526,12 @@ export class AppStore extends PuterStore { } /** - * Record `oldName` as a redirect to the app identified by `appUid`. - * The (app_uid, name) tuple is unique in `old_app_names`, so the - * upsert refreshes the timestamp when the same app re-records the - * same previous name (which happens whenever a user renames back - * and forth) — that resets the TTL clock so the redirect stays live - * for another {@link OLD_APP_NAME_TTL_MONTHS} months. + * Record `oldName` as a redirect to the app identified by `appUid`. The + * (app_uid, name) tuple is unique in `old_app_names`, so the upsert + * refreshes the timestamp when the same app re-records the same previous + * name (which happens whenever a user renames back and forth) — that resets + * the TTL clock so the redirect stays live for another + * {@link OLD_APP_NAME_TTL_MONTHS} months. */ async #recordOldAppName(appUid, oldName) { if (!appUid || typeof oldName !== 'string' || oldName.length === 0) { @@ -540,10 +549,10 @@ export class AppStore extends PuterStore { } /** - * Drop every `old_app_names` row for `name`. Called whenever a name - * is freshly claimed in `apps` (create or rename-into) so the - * authoritative live row isn't shadowed by a stale redirect that - * pointed `name` at some prior owner. + * Drop every `old_app_names` row for `name`. Called whenever a name is + * freshly claimed in `apps` (create or rename-into) so the authoritative + * live row isn't shadowed by a stale redirect that pointed `name` at some + * prior owner. */ async #clearOldAppNamesForName(name) { if (typeof name !== 'string' || name.length === 0) return; @@ -554,8 +563,8 @@ export class AppStore extends PuterStore { } /** - * Delete an app row. Also deletes its filetype associations. - * Invalidates cache. + * Delete an app row. Also deletes its filetype associations. Invalidates + * cache. */ async delete(appId) { const app = await this.getById(appId); @@ -583,9 +592,9 @@ export class AppStore extends PuterStore { } /** - * Batch sibling of {@link getFiletypeAssociations} — one query for many - * app ids, returns `Map` (every requested id is - * present, with `[]` for apps that have no associations). + * Batch sibling of {@link getFiletypeAssociations} — one query for many app + * ids, returns `Map` (every requested id is present, with + * `[]` for apps that have no associations). */ async getFiletypeAssociationsByIds(appIds) { const ids = Array.isArray(appIds) @@ -765,10 +774,10 @@ export class AppStore extends PuterStore { } /** - * Resolve an app by a previously-used name via `old_app_names`. - * Lazy-GCs entries older than {@link OLD_APP_NAME_TTL_MONTHS}: a - * cutoff DELETE runs before the JOIN, so an expired redirect is - * removed and the lookup returns null on the very same call. + * Resolve an app by a previously-used name via `old_app_names`. Lazy-GCs + * entries older than {@link OLD_APP_NAME_TTL_MONTHS}: a cutoff DELETE runs + * before the JOIN, so an expired redirect is removed and the lookup returns + * null on the very same call. */ async #resolveByOldName(name) { const cutoffClause = this.clients.db.case({ @@ -969,9 +978,9 @@ export class AppStore extends PuterStore { /** * Batched, cached all-time { open_count, user_count } for a set of apps. * - * Flow: pipelined redis MGET → on miss, one ClickHouse (or MySQL) query - * for all misses at once → backfill cache. Apps with no rows in - * `app_opens` resolve to zero counts. Returns `Map`. + * Flow: pipelined redis MGET → on miss, one ClickHouse (or MySQL) query for + * all misses at once → backfill cache. Apps with no rows in `app_opens` + * resolve to zero counts. Returns `Map`. */ async getAppsStats(appUids) { const uids = Array.isArray(appUids) @@ -1040,16 +1049,16 @@ export class AppStore extends PuterStore { } /** - * Detailed / period-filtered / grouped stats for a single app. - * Deliberately uncached — UI-driven, rarely repeated with the exact same - * args, and ClickHouse is fast enough at interactive latency. + * Detailed / period-filtered / grouped stats for a single app. Deliberately + * uncached — UI-driven, rarely repeated with the exact same args, and + * ClickHouse is fast enough at interactive latency. * * @param {object} [options] * @param {string} [options.period='all'] — today, yesterday, 7d, 30d, * this_week, last_week, this_month, last_month, this_year, last_year, - * 12m, all + * 12m, all. Default is `'all'` * @param {string} [options.grouping] — hour, day, week, month, year - * @param {number|string|Date} [options.createdAt] — app creation ts; + * @param {number | string | Date} [options.createdAt] — app creation ts; * used to bound the `all` period */ async getAppStatsDetailed(appUid, options = {}) {