mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-24 23:58:47 +00:00
fix: autoclaim app when making a subdomain (#3426)
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
Reference in new issue
Block a user