diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index 3c6e4b0e2..6bb7c251b 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -224,6 +224,8 @@ export type EventMap = { entry: unknown; host: string; requestPath: string; + requestUrl?: string; + requestHash?: string; mime: string; }; @@ -298,7 +300,7 @@ export type LifecyclePhase = 'before' | 'after' | 'error' | 'reject'; /** * Payload for `driver...` events. * - * One shape across all three phases; read `phase` (or the key suffix) to + * One shape across all phases; read `phase` (or the key suffix) to * branch. `allow`/`rejectReason` are only meaningful on the `before` phase * (emitted via `emitAndWait`). */ @@ -308,9 +310,11 @@ export type DriverMethodLifecycleEvent = { method: string; /** Resolved concrete driver name. */ driver: string; + /** Full actor object, if the request is authenticated. */ + actor?: Actor; /** Stable actor id (see `actorUid`), if the request is authenticated. */ - actor?: string; - /** Call arguments. Present on `before`. */ + actorUid?: string; + /** Call arguments. Present on every phase. */ args?: unknown; /** Return value. Present on `after`. */ result?: unknown; @@ -342,8 +346,10 @@ export type RouteLifecycleEvent = { */ req: ExpressRequest; res: ExpressResponse; + /** Full actor object, if the request is authenticated. */ + actor?: Actor; /** Stable actor id (see `actorUid`), if the request is authenticated. */ - actor?: string; + actorUid?: string; /** Response status code. Present on `after`/`error`. */ statusCode?: number; /** Wall-clock duration from `before` to terminal phase. */ diff --git a/src/backend/controllers/drivers/DriverController.test.ts b/src/backend/controllers/drivers/DriverController.test.ts index 700e87859..3a1fd3fa4 100644 --- a/src/backend/controllers/drivers/DriverController.test.ts +++ b/src/backend/controllers/drivers/DriverController.test.ts @@ -491,21 +491,22 @@ describe('DriverController driver-method lifecycle events', () => { server.clients.event.on( 'driver.puter-kvstore.set.before', (_k, data) => { - if (data.actor === who) before.push(data); + if (data.actorUid === who) before.push(data); }, ); server.clients.event.on( 'driver.puter-kvstore.set.after', (_k, data) => { - if (data.actor === who) after.push(data); + if (data.actorUid === who) after.push(data); }, ); + const key = `lc-${uuidv4()}`; const req = makeReq( { interface: 'puter-kvstore', method: 'set', - args: { key: `lc-${uuidv4()}`, value: 'v' }, + args: { key, value: 'v' }, }, actor, ); @@ -522,9 +523,12 @@ describe('DriverController driver-method lifecycle events', () => { phase: 'before', iface: 'puter-kvstore', method: 'set', + actor, + actorUid: who, }); expect(after).toHaveLength(1); expect(after[0].phase).toBe('after'); + expect(after[0].args).toMatchObject({ key, value: 'v' }); expect(typeof after[0].durationMs).toBe('number'); }); @@ -574,6 +578,7 @@ describe('DriverController driver-method lifecycle events', () => { expect(reject[0]).toMatchObject({ phase: 'reject', method: 'set', + args: { key, value: 'v' }, rejectReason: 'blocked in test', }); }); diff --git a/src/backend/controllers/drivers/DriverController.ts b/src/backend/controllers/drivers/DriverController.ts index 9214ad1fd..db2ca8345 100644 --- a/src/backend/controllers/drivers/DriverController.ts +++ b/src/backend/controllers/drivers/DriverController.ts @@ -347,7 +347,8 @@ export class DriverController extends PuterController { iface: ifaceName, method, driver: resolved, - actor, + actor: req.actor, + actorUid: actor, args, allow: true as boolean, rejectReason: undefined as string | undefined, @@ -365,7 +366,9 @@ export class DriverController extends PuterController { iface: ifaceName, method, driver: resolved, - actor, + actor: req.actor, + actorUid: actor, + args, rejectReason: beforeEvent.rejectReason, }, {}, @@ -392,7 +395,9 @@ export class DriverController extends PuterController { iface: ifaceName, method, driver: resolved, - actor, + actor: req.actor, + actorUid: actor, + args, error: e, durationMs: Date.now() - startedAt, }, @@ -407,7 +412,9 @@ export class DriverController extends PuterController { iface: ifaceName, method, driver: resolved, - actor, + actor: req.actor, + actorUid: actor, + args, result, durationMs: Date.now() - startedAt, }, diff --git a/src/backend/core/http/routeLifecycle.test.ts b/src/backend/core/http/routeLifecycle.test.ts index 24a6aaeaf..f940ab44f 100644 --- a/src/backend/core/http/routeLifecycle.test.ts +++ b/src/backend/core/http/routeLifecycle.test.ts @@ -119,7 +119,8 @@ describe('createRouteLifecycleMiddleware', () => { phase: 'before', method: 'post', path: PATH, - actor: 'user:u-1', + actor: { user: { uuid: 'u-1' } }, + actorUid: 'user:u-1', }); // The live req/res are exposed so listeners can read the body or // respond themselves. diff --git a/src/backend/core/http/routeLifecycle.ts b/src/backend/core/http/routeLifecycle.ts index f55027652..1f3be08ee 100644 --- a/src/backend/core/http/routeLifecycle.ts +++ b/src/backend/core/http/routeLifecycle.ts @@ -74,7 +74,14 @@ export const createRouteLifecycleMiddleware = ( return async (req: Request, res, next) => { const actor = req.actor ? actorUid(req.actor) : undefined; const startedAt = Date.now(); - const base = { method, path: pathLabel, req, res, actor }; + const base = { + method, + path: pathLabel, + req, + res, + actor: req.actor, + actorUid: actor, + }; const beforeEvent = { phase: 'before' as const, diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js index 982f94797..261bfa5c7 100644 --- a/src/backend/drivers/apps/AppDriver.js +++ b/src/backend/drivers/apps/AppDriver.js @@ -912,7 +912,9 @@ export class AppDriver extends PuterDriver { // `#ensure_puter_site_subdomain_is_owned`, `#ensureIndexUrlNotAlreadyInUse`, // and the `app:canonicalUidAlias:*` kvstore pair). When a user creates // or repoints an app at a puter-hosted subdomain (`*.puter.site`, - // `*.puter.app`, …) we want exactly one app row to back that URL: + // `*.puter.app`, …) — or at a custom host claimed by an + // `app_origin_aliases` group — we want exactly one app row to back + // that URL: // • If a no-owner row exists (origin-bootstrap stub auto-created // when an unknown origin first hit Puter) → claim it for the // user, merge the new fields into it. @@ -1219,7 +1221,8 @@ export class AppDriver extends PuterDriver { /** * Merge an incoming create/update into an existing app row that - * already owns the same puter-hosted index_url. Returns the joined + * already owns the same puter-hosted or alias-group index_url. + * Returns the joined * (client-shaped) app on success, or `null` when no merge applied. * Throws `app_index_url_already_in_use` when a conflict exists but * cannot be merged (different owner, or same-owner non-bootstrap). @@ -1236,7 +1239,18 @@ export class AppDriver extends PuterDriver { excludeAppId, } = {}) { const indexUrl = object?.index_url; - if (!this.#isPuterHostedIndexUrl(indexUrl)) return null; + // Alias-group hosts (`app_origin_aliases`) get the same merge + // treatment as puter-hosted subdomains. Without this, a bootstrap + // stub on a custom domain could never be absorbed — and since + // `#findIndexUrlConflictRow` *does* honor alias groups, the + // uniqueness check below would hard-reject the owner's own + // create/update instead of merging into the stub. + if ( + !this.#isPuterHostedIndexUrl(indexUrl) && + !this.#findOriginAliasGroupForIndexUrl(indexUrl) + ) { + return null; + } const conflictRow = await this.#findIndexUrlConflictRow({ indexUrl, diff --git a/src/backend/drivers/apps/AppDriver.test.ts b/src/backend/drivers/apps/AppDriver.test.ts index 3b638586c..6da7b3efc 100644 --- a/src/backend/drivers/apps/AppDriver.test.ts +++ b/src/backend/drivers/apps/AppDriver.test.ts @@ -948,3 +948,136 @@ describe('AppDriver.isNameAvailable additional branches', () => { ); }); }); + +// ── alias-group custom domains (`app_origin_aliases`) ────────────── +// +// Custom hosts claimed by an alias group get the same bootstrap-stub +// merge treatment as puter-hosted subdomains: creating or repointing +// an app at an aliased host absorbs the unowned origin-bootstrap row +// instead of rejecting with `app_index_url_already_in_use`. + +describe('AppDriver alias-group index_url merge', () => { + const aliasHostA = `alias-a-${Math.random().toString(36).slice(2, 10)}.test`; + const aliasHostB = `alias-b-${Math.random().toString(36).slice(2, 10)}.test`; + + // `config` is protected on PuterDriver; reach in to toggle the alias + // groups for this block only. `#getOriginAliasGroups` reads config at + // call time, so runtime mutation takes effect immediately. + const driverConfig = () => + (driver as unknown as { config: Record }).config; + + beforeAll(() => { + driverConfig().app_origin_aliases = [[aliasHostA], [aliasHostB]]; + }); + + afterAll(() => { + delete driverConfig().app_origin_aliases; + }); + + const makeBootstrapStub = async (host: string) => { + const stubUid = `app-${uuidv4()}`; + // Mirrors AuthController's get-user-app-token bootstrap path: + // origin persisted as index_url, no owner, name === uid. + await server.stores.app.createFromOrigin(stubUid, `https://${host}`); + return stubUid; + }; + + it('create at an aliased host absorbs the unowned bootstrap stub', async () => { + const { actor, userId } = await makeUser(); + const stubUid = await makeBootstrapStub(aliasHostA); + const name = uniqueName('alias-create'); + + const result = await withActor(actor, () => + driver.create({ + object: { + name, + title: 'Aliased', + index_url: `https://${aliasHostA}/`, + }, + }), + ); + + // The stub row survives as the canonical app, claimed + merged. + 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 another user registering an app under a reserved aliased host', async () => { + const other = await makeUser(); + await expect( + withActor(other.actor, () => + driver.create({ + object: { + name: uniqueName('squatter'), + title: 't', + index_url: `https://${aliasHostA}/index.html`, + }, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('update repointing to an aliased host merges and aliases the old uid', async () => { + const { actor, userId } = await makeUser(); + const stubUid = await makeBootstrapStub(aliasHostB); + + const created = await withActor(actor, () => + driver.create({ + object: { + name: uniqueName('alias-upd'), + title: 't', + index_url: uniqueIndexUrl(), + }, + }), + ); + + const updated = await withActor(actor, () => + driver.update({ + uid: created.uid, + object: { index_url: `https://${aliasHostB}/` }, + }), + ); + + // Merged into the stub; source row deleted; old uid still resolves + // via the canonical-uid alias. + expect(updated.uid).toBe(stubUid); + expect(await server.stores.app.getByUid(created.uid as string)).toBeNull(); + const stored = await server.stores.app.getByUid(stubUid); + expect(stored?.owner_user_id).toBe(userId); + + const viaOldUid = await withActor(actor, () => + driver.read({ uid: created.uid }), + ); + expect(viaOldUid.uid).toBe(stubUid); + }); + + it('leaves unrelated custom domains untouched (no alias group, no conflict check)', async () => { + const a = await makeUser(); + const b = await makeUser(); + const sharedUrl = uniqueIndexUrl(); + + // Non-puter, non-aliased hosts keep their historical behavior: + // no uniqueness enforcement, both creates succeed. + const first = await withActor(a.actor, () => + driver.create({ + object: { + name: uniqueName('plain-a'), + title: 't', + index_url: sharedUrl, + }, + }), + ); + const second = await withActor(b.actor, () => + driver.create({ + object: { + name: uniqueName('plain-b'), + title: 't', + index_url: sharedUrl, + }, + }), + ); + expect(first.uid).not.toBe(second.uid); + }); +});