fix: autoclaim app when making a subdomain (#3426)

This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-07-22 21:12:38 -07:00
1 parent 928d5fec16
commit 5faed55076
6 files changed
+483 -291

No files matched your search

@@ -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);
});
});
+58 -54
View File
@@ -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 <https://www.gnu.org/licenses/>.
* 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:<error_id>` for a week (the admin
* abuse page looks it up there without needing log access), and returns
* the `HttpError` with the id attached as `error_id` for the GUI to
* surface. The phone number is deliberately omitted from the log line and
* the KV record (PII); the user + country are enough to correlate.
* 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<void> {
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<void> {
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,
@@ -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(
@@ -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.
+205 -165
View File
@@ -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 <https://www.gnu.org/licenses/>.
* 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<typeof puterServices>;
@@ -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<Array<Record<string, unknown>>> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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<string> {
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<number | null> {
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<string>([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,
+80 -71
View File
@@ -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 <https://www.gnu.org/licenses/>.
* 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<appId, string[]>` (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<appId, string[]>` (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<uid, stats>`.
* 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<uid, stats>`.
*/
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 = {}) {