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 = {}) {