diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index a572fbb50..425fb2d8f 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -850,11 +850,10 @@ export type EventKey = keyof EventMap & string; // Generates a wildcard for every non-final dot-separated prefix of K. export type WildcardPrefixes = K extends `${infer Head}.${infer Tail}` - ? - | `${Head}.*` - | (Tail extends `${string}.${string}` - ? `${Head}.${WildcardPrefixes}` - : never) + ? | `${Head}.*` + | (Tail extends `${string}.${string}` + ? `${Head}.${WildcardPrefixes}` + : never) : never; export type ListenKey = EventKey | WildcardPrefixes; diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 97790ec4c..4a1273233 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -3120,6 +3120,39 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => { expect(bootstrapped?.owner_user_id).toBe(owner!.id); }); + it('canonicalizes alternate-host origins to one bootstrap row per subdomain', async () => { + const subdomain = `sd-${uuidv4().slice(0, 8)}`; + await server.stores.subdomain.create({ userId: user.id, subdomain }); + + const res = makeRes(); + await inCtx(actor, () => + controller.handleGetUserAppToken( + makeReq( + { origin: `https://${subdomain}.host.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?.index_url).toBe( + `http://${subdomain}.site.puter.localhost`, + ); + + const res2 = makeRes(); + await inCtx(actor, () => + controller.handleGetUserAppToken( + makeReq( + { origin: `https://${subdomain}.app.puter.localhost` }, + { actor }, + ), + res2, + ), + ); + expect((res2.body as { app_uid: string }).app_uid).toBe(body.app_uid); + }); + it('supports browser extension origins in handleGetUserAppToken', async () => { // Random id: a pre-existing row for this origin would resolve through // the canonical lookup and never exercise the bootstrap path. @@ -5133,7 +5166,9 @@ describe('AuthController password recovery', () => { expect((res.body as { message: string }).message).toMatch( /If that account exists/i, ); - const after = await server.stores.user.getById(seat.id, { force: true }); + const after = await server.stores.user.getById(seat.id, { + force: true, + }); expect(after!.pass_recovery_token).toBeFalsy(); }); @@ -5398,7 +5433,10 @@ describe('AuthController user-protected mutations (validation paths)', () => { await expect( controller.handleChangeEmail( - makeReq({ new_email: `moved_${uniq()}@example.com` }, { actor }), + makeReq( + { new_email: `moved_${uniq()}@example.com` }, + { actor }, + ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 403 }); @@ -5426,7 +5464,9 @@ describe('AuthController user-protected mutations (validation paths)', () => { ), ).rejects.toMatchObject({ statusCode: 403 }); - const after = await server.stores.user.getById(seat.id, { force: true }); + const after = await server.stores.user.getById(seat.id, { + force: true, + }); expect(after!.username).toBe(seat.username); }); @@ -5972,10 +6012,7 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () => await inCtx(actor, () => controller.handleGrantUserApp( - makeReq( - { app_uid: app.uid, permission, extra: {} }, - { actor }, - ), + makeReq({ app_uid: app.uid, permission, extra: {} }, { actor }), makeRes(), ), ); @@ -6012,7 +6049,10 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () => inCtx(appActor, () => controller.handleCheckPermissions( makeReq( - { permissions: ['service:foo:ii:read'], app_uid: app.uid }, + { + permissions: ['service:foo:ii:read'], + app_uid: app.uid, + }, { actor: appActor }, ), makeRes(), @@ -6857,7 +6897,9 @@ describe('AuthController.handleDeleteOwnUser', () => { controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()), ).rejects.toMatchObject({ statusCode: 403 }); - const after = await server.stores.user.getById(seat.id, { force: true }); + const after = await server.stores.user.getById(seat.id, { + force: true, + }); expect(after).toBeTruthy(); }); diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 0cabb74d0..46e5f3143 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -4030,11 +4030,18 @@ export class AuthController extends PuterController { if (!app && resolvedFromOrigin) { // Hosted-subdomain origins get the site owner stamped as the // app's creator at bootstrap; external origins stay unowned. + // Canonical origin only, so alternate hosts share one row. + const canonicalOrigin = + this.services.auth.canonicalizeOrigin(origin); const ownerUserId = - await this.services.auth.subdomainOwnerIdFromOrigin(origin); - app = await this.stores.app.createFromOrigin(app_uid, origin, { - ownerUserId, - }); + await this.services.auth.subdomainOwnerIdFromOrigin( + canonicalOrigin, + ); + app = await this.stores.app.createFromOrigin( + app_uid, + canonicalOrigin, + { ownerUserId }, + ); // An origin's uid is a deterministic uuidv5, so a deleted app // reappears here under the identical uid. Withdraw any cross-app // data grants left pointing at it before this new row can inherit diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index 767648191..1c2c51e82 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -869,9 +869,7 @@ export class LegacyFSController extends PuterController { // Trash, and `null`/`{}` when restoring. See // `src/gui/src/helpers.js` → `window.move_items`. newMetadata: (body.new_metadata ?? undefined) as - | Record - | null - | undefined, + Record | null | undefined, }); const oldPath = source.path; await this.#emitGuiEvent('outer.gui.item.moved', moved, { @@ -1317,8 +1315,7 @@ export class LegacyFSController extends PuterController { } type SignedOrEmpty = - | (SignedFile & { path?: string }) - | Record; + (SignedFile & { path?: string }) | Record; const result: { signatures: SignedOrEmpty[]; token?: string } = { signatures: [], }; @@ -1941,10 +1938,7 @@ export class LegacyFSController extends PuterController { const subjectRef = body.subject; const appRef = body.app; const mode = (getString(body, 'mode') ?? 'read') as - | 'see' - | 'list' - | 'read' - | 'write'; + 'see' | 'list' | 'read' | 'write'; if (!subjectRef || !appRef) throw new HttpError(400, '`subject` and `app` are required', { legacyCode: 'bad_request', diff --git a/src/backend/core/http/middleware/privateAppGate.test.ts b/src/backend/core/http/middleware/privateAppGate.test.ts index e5e142c17..966775bd3 100644 --- a/src/backend/core/http/middleware/privateAppGate.test.ts +++ b/src/backend/core/http/middleware/privateAppGate.test.ts @@ -679,6 +679,27 @@ describe('resolveOwnedAppForHostedSite', () => { expect(out).toBeNull(); }); + it('prefers the private app over an older public bootstrap stub on an alternate host', async () => { + const owner = await makeUser(); + await server.stores.app.createFromOrigin( + `app-${uuidv4()}`, + 'http://beans.host.puter.localhost', + { ownerUserId: owner.id }, + ); + const app = await createPrivateApp( + owner.id, + 'http://beans.app.puter.localhost/', + ); + const out = await resolveOwnedAppForHostedSite({ + req: reqOf('beans.app.puter.localhost'), + site: { user_id: owner.id }, + db: server.clients.db, + config: baseConfig(), + }); + expect(out?.uid).toBe(app.uid); + expect(Boolean(out?.is_private)).toBe(true); + }); + it('returns null when the site has no owner', async () => { const out = await resolveOwnedAppForHostedSite({ req: reqOf('beans.site.puter.localhost'), diff --git a/src/backend/core/http/middleware/privateAppGate.ts b/src/backend/core/http/middleware/privateAppGate.ts index e75bd2c8b..cd0c95744 100644 --- a/src/backend/core/http/middleware/privateAppGate.ts +++ b/src/backend/core/http/middleware/privateAppGate.ts @@ -249,8 +249,10 @@ export async function resolveOwnedAppForHostedSite(opts: { ? `AND \`is_private\` = ${opts.db.booleanLiteral(true)} ` : ''; const placeholders = uniqueCandidates.map(() => '?').join(', '); + // Ambiguity fails closed: a private row wins; `id` keeps it deterministic. + const orderClause = `ORDER BY CASE WHEN \`is_private\` = ${opts.db.booleanLiteral(true)} THEN 0 ELSE 1 END, \`id\``; const rows = await opts.db.read( - `SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) LIMIT 2`, + `SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) ${orderClause} LIMIT 2`, [opts.site.user_id, ...uniqueCandidates], ); if (rows.length === 0) return null; diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js index 07308a24a..cf8d774e1 100644 --- a/src/backend/drivers/apps/AppDriver.js +++ b/src/backend/drivers/apps/AppDriver.js @@ -32,6 +32,7 @@ import { import { isUniqueViolation } from '../../util/dbError.js'; import { buildHostedBackingDenial, + buildHostedSubdomainIndexUrlCandidates, extractPuterHostedSubdomain, hostedIndexUrlBackingIsUnavailable, } from '../../util/hostedAppBacking.js'; @@ -1171,6 +1172,17 @@ export class AppDriver extends PuterDriver { this.#buildEquivalentIndexUrlCandidates(indexUrl), ); + // The same subdomain on any other hosting domain is the same site. + const hostedSubdomain = this.#extractPuterHostedSubdomain(indexUrl); + if (hostedSubdomain) { + for (const candidate of buildHostedSubdomainIndexUrlCandidates( + hostedSubdomain, + this.config, + )) { + candidates.add(candidate); + } + } + // For alias-group hosts, treat the group as a host-level reservation: // any row whose index_url is the root URL of any group member counts // as a conflict, so a single app owns the whole group. diff --git a/src/backend/drivers/apps/AppDriver.test.ts b/src/backend/drivers/apps/AppDriver.test.ts index 6adbeb17b..90ee70216 100644 --- a/src/backend/drivers/apps/AppDriver.test.ts +++ b/src/backend/drivers/apps/AppDriver.test.ts @@ -1610,6 +1610,34 @@ describe('AppDriver hosted-subdomain ownership check', () => { expect(stored?.owner_user_id).toBe(userId); }); + it('create absorbs a bootstrap stub minted on an alternate hosting domain', async () => { + const { actor, userId } = await makeUser(); + const sub = uniqueName('altstub'); + await server.stores.subdomain.create({ userId, subdomain: sub }); + const stubUid = `app-${uuidv4()}`; + await server.stores.app.createFromOrigin( + stubUid, + `https://${sub}.host.puter.localhost`, + { ownerUserId: userId }, + ); + + const name = uniqueName('alt-create'); + const result = await withActor(actor, () => + driver.create({ + object: { + name, + title: 'Alt-host 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?.index_url).toBe(hostedUrl(sub)); + }); + 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 9f67606c4..577d47a29 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -1626,6 +1626,52 @@ describe('AuthService (integration)', () => { expect(a).toMatch(/^app-/); }); + it('resolves every hosting variant of a subdomain to the same uid', async () => { + const sub = `canon-${Math.random().toString(36).slice(2, 10)}`; + const uids = await Promise.all([ + authService.appUidFromOrigin( + `https://${sub}.site.puter.localhost`, + ), + authService.appUidFromOrigin( + `https://${sub}.host.puter.localhost`, + ), + authService.appUidFromOrigin( + `http://${sub}.app.puter.localhost`, + ), + authService.appUidFromOrigin( + `https://${sub}.dev.puter.localhost`, + ), + ]); + expect(new Set(uids).size).toBe(1); + }); + + it('prefers the private app row over an older public stub across hosting variants', async () => { + const user = await makeUser(); + const sub = `pref-${Math.random().toString(36).slice(2, 10)}`; + await server.stores.app.createFromOrigin( + `app-${uuidv4()}`, + `https://${sub}.host.puter.localhost`, + { ownerUserId: user.id }, + ); + const realUid = `app-${uuidv4()}`; + await server.clients.db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`, `is_private`) VALUES (?, ?, ?, ?, ?, ?)', + [ + realUid, + `real-${sub}`, + 'Real app', + `https://${sub}.app.puter.localhost`, + user.id, + 1, + ], + ); + await expect( + authService.appUidFromOrigin( + `https://${sub}.host.puter.localhost`, + ), + ).resolves.toBe(realUid); + }); + it.each([ 'javascript:alert(document.domain)', 'data:text/html,', diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index f0df2a8a7..d20d7c0cf 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -784,11 +784,11 @@ export class AuthService extends PuterService { legacyCode: 'bad_request', }); } - // Aliased hosts collapse to a single canonical representative so the - // event listeners and the UUIDv5 fallback resolve to the same value - // for every member of an alias group. + // Aliased hosts and hosting-domain variants collapse to one canonical + // origin, so every spelling of the same app resolves to one uid. const aliased = this.#canonicalizeAliasedOrigin(parsed) ?? parsed; - const event = { origin: aliased }; + const canonical = this.#canonicalizeHostedOrigin(aliased) ?? aliased; + const event = { origin: canonical }; await this.clients.event?.emitAndWait('app.from-origin', event, {}); // Blocked origins can't acquire an app token (or have one minted / @@ -925,6 +925,39 @@ export class AuthService extends PuterService { return `${parsed.protocol}//${parsed.hostname.toLowerCase()}${port}`; } + /** Same subdomain on the primary hosting domain; null when not hosted. */ + #canonicalizeHostedOrigin(origin: string): string | null { + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + return null; + } + const hostingDomains = this.#getHostingDomains(); + const subdomain = this.#hostedSubdomainForHost( + parsed.host.toLowerCase(), + parsed.hostname.toLowerCase(), + hostingDomains, + ); + if (!subdomain) return null; + const canonicalDomain = hostingDomains[0]; + if (!canonicalDomain) return null; + const config = this.config as { protocol?: string }; + const protocol = + (typeof config.protocol === 'string' + ? config.protocol.trim().replace(/:$/, '') + : '') || 'https'; + return `${protocol}://${subdomain}.${canonicalDomain}`; + } + + /** Canonical origin across alias groups and hosting domains. */ + canonicalizeOrigin(origin: string): string { + const parsed = this.#originFromUrl(origin); + if (!parsed) return origin; + const aliased = this.#canonicalizeAliasedOrigin(parsed) ?? parsed; + return this.#canonicalizeHostedOrigin(aliased) ?? aliased; + } + /** * Configured hosting domains (`static_hosting_domain(_alt)` + * `private_app_hosting_domain(_alt)`), normalized, each in both raw @@ -983,8 +1016,8 @@ export class AuthService extends PuterService { * * Build candidate URLs from the origin's subdomain crossed with every * configured hosting domain (static + private, with and without ports). - * Prefer the oldest matching app for deterministic tie-breaking across - * historically-duplicated rows. + * Prefer private rows, then the oldest match, for deterministic + * tie-breaking across historically-duplicated rows. */ async #findCanonicalAppUidForOrigin( origin: string, @@ -1045,8 +1078,10 @@ export class AuthService extends PuterService { if (uniqueCandidates.length === 0) return null; const placeholders = uniqueCandidates.map(() => '?').join(', '); + // Private rows win over public duplicates; oldest id breaks ties. const rows = (await this.clients.db.read( - `SELECT \`uid\` FROM \`apps\` WHERE \`index_url\` IN (${placeholders}) ORDER BY \`id\` ASC LIMIT 1`, + `SELECT \`uid\` FROM \`apps\` WHERE \`index_url\` IN (${placeholders}) ` + + `ORDER BY CASE WHEN \`is_private\` = ${this.clients.db.booleanLiteral(true)} THEN 0 ELSE 1 END, \`id\` ASC LIMIT 1`, uniqueCandidates, )) as Array<{ uid?: string }>; const uid = rows[0]?.uid; diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts index da82e190c..86bad29bb 100644 --- a/src/backend/services/team/TeamService.ts +++ b/src/backend/services/team/TeamService.ts @@ -762,18 +762,16 @@ export class TeamService extends PuterService { id === null ? null : (users.get(id)?.username ?? null); return { - items: page.items.map( - (row): MemberActivityEntry => ({ - action: row.action, - reason: row.reason, - created_at: epochSeconds(row.created_at), - username: name(row.user_id_keep), - actor_username: name(row.actor_user_id), - // Only a sign-in carries these; the shape stays uniform. - ip: null, - user_agent: null, - }), - ), + items: page.items.map((row): MemberActivityEntry => ({ + action: row.action, + reason: row.reason, + created_at: epochSeconds(row.created_at), + username: name(row.user_id_keep), + actor_username: name(row.actor_user_id), + // Only a sign-in carries these; the shape stays uniform. + ip: null, + user_agent: null, + })), ...(page.cursor ? { cursor: page.cursor } : {}), }; } diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index 082ca4cbd..518693685 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -148,10 +148,7 @@ const RESERVED_HANDLES = new Set([ ]); export type HandleRejection = - | 'too_short' - | 'too_long' - | 'malformed' - | 'reserved'; + 'too_short' | 'too_long' | 'malformed' | 'reserved'; /** Trimmed and capped, so the same name is accepted on every engine. */ export const normalizeTeamName = (name: string): string => {