From d039dcfee5b8c1a3b19c149f9735b66e30a89f95 Mon Sep 17 00:00:00 2001 From: Neal Shah <30693865+ProgrammerIn-wonderland@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:10:13 -0700 Subject: [PATCH] fix appTelemetry (#3357) --- extensions/appTelemetry.test.ts | 200 ++++++++++---------------------- extensions/appTelemetry.ts | 168 ++++++++++++++------------- src/backend/server.ts | 45 ++++++- 3 files changed, 189 insertions(+), 224 deletions(-) diff --git a/extensions/appTelemetry.test.ts b/extensions/appTelemetry.test.ts index 5423b8b30..6c0021fe2 100644 --- a/extensions/appTelemetry.test.ts +++ b/extensions/appTelemetry.test.ts @@ -1,75 +1,66 @@ -import type { Request, Response } from 'express'; import { v4 as uuidv4 } from 'uuid'; -import { - afterAll, - beforeAll, - describe, - expect, - it, - vi, -} from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { runWithContext } from '../src/backend/core/context.ts'; import { PuterServer } from '../src/backend/server.ts'; import { setupTestServer } from '../src/backend/testUtil.ts'; -import { - handleAppTelemetryUserCount, - handleAppTelemetryUsers, -} from './appTelemetry.ts'; - -interface CapturedResponse { - body: unknown; -} - -const makeReq = (query: Record = {}): Request => - ({ query }) as unknown as Request; - -const makeRes = () => { - const captured: CapturedResponse = { body: undefined }; - const res = { - json: vi.fn((value: unknown) => { - captured.body = value; - return res; - }), - status: vi.fn(() => res), - }; - return { res: res as unknown as Response, captured }; -}; - -// `handleAppTelemetryUsers` calls `Context.get('actor')` for the permission -// gate. Wrap test calls in `runWithContext` so the ALS lookup resolves. -const callWithActor = async ( - actor: { user: { uuid: string; id?: number }; app?: null } | undefined, - fn: () => Promise, -) => runWithContext({ actor }, fn); +// Importing the module registers the `appTelemetry` driver into the shared +// extensionStore, so `setupTestServer` instantiates it on `server.drivers`. +import { AppTelemetryDriver } from './appTelemetry.ts'; let server: PuterServer; +let driver: AppTelemetryDriver; + +// `get_users` reads `Context.get('actor')` for the ownership gate. Wrap +// actor-dependent calls in `runWithContext` so the ALS lookup resolves. +const callWithActor = ( + actor: { user: { uuid: string; id?: number } } | undefined, + fn: () => Promise, +) => runWithContext({ actor }, fn); beforeAll(async () => { server = await setupTestServer(); + driver = (server.drivers as unknown as Record) + .appTelemetry; + // Guard: the driver must have been wired onto the server. If this is + // undefined the extension didn't register, and every test below would + // fail with a confusing "cannot read property of undefined". + expect(driver).toBeInstanceOf(AppTelemetryDriver); }); afterAll(async () => { await server.shutdown(); }); -describe('appTelemetry extension — handleAppTelemetryUsers', () => { +const seedOwnedApp = async (prefix: string) => { + const owner = await server.stores.user.create({ + username: `${prefix}_${Math.random().toString(36).slice(2, 8)}`, + uuid: uuidv4(), + password: 'x', + email: null, + }); + const slug = Math.random().toString(36).slice(2, 8); + const app = await server.stores.app.create( + { + name: `${prefix}_${slug}`, + title: `${prefix} ${slug}`, + index_url: `https://example.com/${slug}`, + }, + { ownerUserId: owner.id as number }, + ); + return { owner, app: app! }; +}; + +describe('appTelemetry driver — get_users', () => { it('throws HttpError(400) when app_uuid is missing', async () => { - const { res } = makeRes(); - await expect( - handleAppTelemetryUsers(makeReq({}), res), - ).rejects.toMatchObject({ + await expect(driver.get_users({})).rejects.toMatchObject({ statusCode: 400, message: expect.stringContaining('app_uuid'), }); }); it('throws HttpError(400) for a non-numeric limit', async () => { - const { res } = makeRes(); await expect( - handleAppTelemetryUsers( - makeReq({ app_uuid: 'app-anything', limit: 'banana' }), - res, - ), + driver.get_users({ app_uuid: 'app-anything', limit: 'banana' }), ).rejects.toMatchObject({ statusCode: 400, message: expect.stringContaining('limit'), @@ -77,22 +68,14 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => { }); it('throws HttpError(400) when offset exceeds the allowed maximum', async () => { - const { res } = makeRes(); await expect( - handleAppTelemetryUsers( - makeReq({ app_uuid: 'app-anything', offset: 1_000_001 }), - res, - ), + driver.get_users({ app_uuid: 'app-anything', offset: 1_000_001 }), ).rejects.toMatchObject({ statusCode: 400 }); }); it('throws HttpError(404) when the app cannot be found', async () => { - const { res } = makeRes(); await expect( - handleAppTelemetryUsers( - makeReq({ app_uuid: 'app-does-not-exist' }), - res, - ), + driver.get_users({ app_uuid: 'app-does-not-exist' }), ).rejects.toMatchObject({ statusCode: 404, message: 'App not found', @@ -100,25 +83,10 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => { }); it('throws HttpError(403) when the caller does not own the app', async () => { - // Seed an owner user + an app belonging to them, then call the - // handler as a *different* user. The real permission service should - // reject since the caller has no `apps-of-user::write` perm. - const owner = await server.stores.user.create({ - username: `owner_${Math.random().toString(36).slice(2, 8)}`, - uuid: uuidv4(), - password: 'x', - email: null, - }); - const slug = Math.random().toString(36).slice(2, 8); - const app = await server.stores.app.create( - { - name: `app_${slug}`, - title: `App ${slug}`, - index_url: `https://example.com/${slug}`, - }, - { ownerUserId: owner.id as number }, - ); - + // Seed an owner + their app, then call as a *different* user. The + // real permission service should reject: the caller has no + // `apps-of-user::write`. + const { app } = await seedOwnedApp('owner'); const intruder = await server.stores.user.create({ username: `intruder_${Math.random().toString(36).slice(2, 8)}`, uuid: uuidv4(), @@ -126,15 +94,11 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => { email: null, }); - const { res } = makeRes(); await callWithActor( { user: { uuid: intruder.uuid, id: intruder.id as number } }, async () => { await expect( - handleAppTelemetryUsers( - makeReq({ app_uuid: app!.uid }), - res, - ), + driver.get_users({ app_uuid: app.uid }), ).rejects.toMatchObject({ statusCode: 403, message: 'Permission denied', @@ -144,78 +108,32 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => { }); it('returns an empty list for an owned app with no authenticated users', async () => { - const owner = await server.stores.user.create({ - username: `owner2_${Math.random().toString(36).slice(2, 8)}`, - uuid: uuidv4(), - password: 'x', - email: null, - }); - const slug = Math.random().toString(36).slice(2, 8); - const app = await server.stores.app.create( - { - name: `app2_${slug}`, - title: `App2 ${slug}`, - index_url: `https://example.com/${slug}`, - }, - { ownerUserId: owner.id as number }, - ); + const { owner, app } = await seedOwnedApp('owner2'); - const { res, captured } = makeRes(); - await callWithActor( + const result = await callWithActor( { user: { uuid: owner.uuid, id: owner.id as number } }, - async () => { - await handleAppTelemetryUsers( - makeReq({ app_uuid: app!.uid }), - res, - ); - }, + () => driver.get_users({ app_uuid: app.uid }), ); - expect(captured.body).toEqual([]); + expect(result).toEqual([]); }); }); -describe('appTelemetry extension — handleAppTelemetryUserCount', () => { +describe('appTelemetry driver — user_count', () => { it('throws HttpError(400) when app_uuid is missing', async () => { - const { res } = makeRes(); - await expect( - handleAppTelemetryUserCount(makeReq({}), res), - ).rejects.toMatchObject({ statusCode: 400 }); + await expect(driver.user_count({})).rejects.toMatchObject({ + statusCode: 400, + }); }); it('throws HttpError(404) for an unknown app_uuid', async () => { - const { res } = makeRes(); await expect( - handleAppTelemetryUserCount( - makeReq({ app_uuid: 'app-not-here' }), - res, - ), + driver.user_count({ app_uuid: 'app-not-here' }), ).rejects.toMatchObject({ statusCode: 404 }); }); - it('returns { count: 0 } for an app with no authenticated users', async () => { - const owner = await server.stores.user.create({ - username: `counto_${Math.random().toString(36).slice(2, 8)}`, - uuid: uuidv4(), - password: 'x', - email: null, - }); - const slug = Math.random().toString(36).slice(2, 8); - const app = await server.stores.app.create( - { - name: `appcount_${slug}`, - title: `AppCount ${slug}`, - index_url: `https://example.com/${slug}`, - }, - { ownerUserId: owner.id as number }, - ); - - const { res, captured } = makeRes(); - await handleAppTelemetryUserCount( - makeReq({ app_uuid: app!.uid }), - res, - ); - - expect(captured.body).toEqual({ count: 0 }); + it('returns 0 for an app with no authenticated users', async () => { + const { app } = await seedOwnedApp('appcount'); + await expect(driver.user_count({ app_uuid: app.uid })).resolves.toBe(0); }); }); diff --git a/extensions/appTelemetry.ts b/extensions/appTelemetry.ts index 48503dbce..00eec4c19 100644 --- a/extensions/appTelemetry.ts +++ b/extensions/appTelemetry.ts @@ -1,11 +1,16 @@ -import type { Request, Response } from 'express'; import { Context } from '@heyputer/backend/src/core'; +import type { Actor } from '@heyputer/backend/src/core/actor'; import { HttpError } from '@heyputer/backend/src/core/http'; +import { PuterDriver } from '@heyputer/backend/src/drivers/types'; import { extension } from '@heyputer/backend/src/extensions'; -const clients = extension.import('client'); -const stores = extension.import('store'); -const services = extension.import('service'); +// App-telemetry lets an app owner enumerate the users who have +// authenticated into their app. v1 shipped this as a driver on the +// `app-telemetry` interface (methods `get_users` / `user_count`) and +// puter-js's `puter.apps(...).getUsers()` still calls it that way +// (`puter.drivers.call('app-telemetry', 'app-telemetry', 'get_users', …)`). +// This is the v2 port of that driver — same interface/method/return shapes +// so existing puter-js callers work unchanged. const DEFAULT_LIMIT = 100; const MAX_LIMIT = 1000; @@ -41,91 +46,94 @@ const parseIntParam = ( return parsed; }; -export const handleAppTelemetryUsers = async ( - req: Request, - res: Response, -): Promise => { - const { app_uuid } = req.query as Record; - if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`'); +/** + * Driver exposing the `app-telemetry` interface. + * + * The `/drivers/call` permission gate checks + * `service:app-telemetry:ii:app-telemetry`, which every actor already holds + * via the blanket `service` grant (hardcoded-permissions.js + + * `default_implicit_user_app_permissions`). The real authorization — "is the + * caller the app owner?" — is enforced inside `get_users` below, exactly as + * v1 did. + */ +export class AppTelemetryDriver extends PuterDriver { + readonly driverInterface = 'app-telemetry'; + readonly driverName = 'app-telemetry'; + readonly isDefault = true; - const safeLimit = parseIntParam(req.query.limit, { - key: 'limit', - min: 1, - max: MAX_LIMIT, - fallback: DEFAULT_LIMIT, - }); - const safeOffset = parseIntParam(req.query.offset, { - key: 'offset', - min: 0, - max: MAX_OFFSET, - fallback: 0, - }); + /** Users who have authenticated into the given app (owner-only). */ + async get_users({ + app_uuid, + limit, + offset, + }: { + app_uuid?: string; + limit?: unknown; + offset?: unknown; + } = {}): Promise> { + if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`'); - const app = await stores.app.getByUid(app_uuid); - if (!app) throw new HttpError(404, 'App not found'); + const safeLimit = parseIntParam(limit, { + key: 'limit', + min: 1, + max: MAX_LIMIT, + fallback: DEFAULT_LIMIT, + }); + const safeOffset = parseIntParam(offset, { + key: 'offset', + min: 0, + max: MAX_OFFSET, + fallback: 0, + }); - // `apps-of-user::write` — the implicator keys on the owner's - // UUID, not the numeric id. Look up the owner explicitly. v1 got - // this for free because its entity-storage layer eager-joined the - // owner row; v2's AppStore.getByUid returns the raw row with only - // `owner_user_id` populated. - const ownerId = (app as { owner_user_id?: number }).owner_user_id; - if (!ownerId) throw new HttpError(404, 'App owner not found'); - const owner = (await stores.user.getById(ownerId)) as { - uuid?: string; - } | null; - if (!owner?.uuid) throw new HttpError(404, 'App owner not found'); + const app = await this.stores.app.getByUid(app_uuid); + if (!app) throw new HttpError(404, 'App not found'); - const actor = Context.get('actor'); - const ownsApp = await services.permission - .check(actor!, `apps-of-user:${owner.uuid}:write`) - .catch(() => false); - if (!ownsApp) throw new HttpError(403, 'Permission denied'); + // The `apps-of-user::write` implicator keys on the owner's + // UUID, not the numeric id. Look up the owner explicitly — the raw + // app row only carries `owner_user_id`. (v1 got the owner for free + // because its entity-storage layer eager-joined the owner row.) + const ownerId = (app as { owner_user_id?: number }).owner_user_id; + if (!ownerId) throw new HttpError(404, 'App owner not found'); + const owner = await this.stores.user.getById(ownerId); + if (!owner?.uuid) throw new HttpError(404, 'App owner not found'); - const users = await clients.db.read( - `SELECT u.username, u.uuid FROM user_to_app_permissions p - INNER JOIN ${clients.db.quoteIdentifier('user')} u ON p.user_id = u.id - WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ? - ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id - LIMIT ? OFFSET ?`, - [(app as Record).id, safeLimit, safeOffset], - ); + const actor = Context.get('actor'); + if (!actor) throw new HttpError(401, 'Authentication required'); + const ownsApp = await this.services.permission + .check(actor as Actor, `apps-of-user:${owner.uuid}:write`) + .catch(() => false); + if (!ownsApp) throw new HttpError(403, 'Permission denied'); - res.json( - (users as Array<{ username: string; uuid: string }>).map((e) => ({ - user: e.username, - user_uuid: e.uuid, - })), - ); -}; + const users = (await this.clients.db.read( + `SELECT u.username, u.uuid FROM user_to_app_permissions p + INNER JOIN ${this.clients.db.quoteIdentifier('user')} u ON p.user_id = u.id + WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ? + ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id + LIMIT ? OFFSET ?`, + [(app as { id: number }).id, safeLimit, safeOffset], + )) as Array<{ username: string; uuid: string }>; -export const handleAppTelemetryUserCount = async ( - req: Request, - res: Response, -): Promise => { - const { app_uuid } = req.query as Record; - if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`'); + return users.map((e) => ({ user: e.username, user_uuid: e.uuid })); + } - const app = await stores.app.getByUid(app_uuid); - if (!app) throw new HttpError(404, 'App not found'); + /** Count of users who have authenticated into the given app. */ + async user_count({ + app_uuid, + }: { app_uuid?: string } = {}): Promise { + if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`'); - const [row] = (await clients.db.read( - `SELECT COUNT(*) AS n FROM user_to_app_permissions - WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`, - [(app as Record).id], - )) as Array<{ n: number }>; + const app = await this.stores.app.getByUid(app_uuid); + if (!app) throw new HttpError(404, 'App not found'); - res.json({ count: row?.n ?? 0 }); -}; + const [row] = (await this.clients.db.read( + `SELECT COUNT(*) AS n FROM user_to_app_permissions + WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`, + [(app as { id: number }).id], + )) as Array<{ n: number }>; -extension.get( - '/app-telemetry/users', - { subdomain: 'api', requireAuth: true }, - handleAppTelemetryUsers, -); + return row?.n ?? 0; + } +} -extension.get( - '/app-telemetry/user-count', - { subdomain: 'api', requireAuth: true }, - handleAppTelemetryUserCount, -); +extension.registerDriver('appTelemetry', AppTelemetryDriver); diff --git a/src/backend/server.ts b/src/backend/server.ts index 9dcea8f4e..3152ef056 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -25,7 +25,9 @@ import type { Application, RequestHandler } from 'express'; import helmet from 'helmet'; import uaParser from 'ua-parser-js'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; import http from 'node:http'; import { puterClients } from './clients'; import { puterControllers } from './controllers'; @@ -1145,9 +1147,46 @@ export class PuterServer { await this.#fireOnServerStart(); console.log('PuterServer has fully booted.'); - // Auto-launch the browser on dev boot (matches v1 WebServerService). - // Opt out via `no_browser_launch: true` in config. - if (this.#config.env === 'dev' && !cfg.no_browser_launch) { + + // CLI: `--server` (optionally `--puter-backend=`) + // runs the AuthMe flow against a remote Puter (default + // puter.com), then opens the local GUI already logged in and + // pointed at that backend. Restores the v1 WebServerService + // `--server` behavior; works in any env. When set, it takes + // over browser launch so we don't also open a plain tab. + const { values: cliArgs } = parseArgs({ + args: process.argv.slice(2), + options: { + server: { type: 'boolean' }, + 'puter-backend': { type: 'string' }, + }, + strict: false, + }); + + if (cliArgs.server) { + try { + // tools/auth_gui.js is not compiled into dist/, so + // resolve it from the package root (cwd, per the + // `start` script) rather than relative to this module. + const authGuiUrl = pathToFileURL( + path.resolve(process.cwd(), 'tools/auth_gui.js'), + ).href; + const authGui = (await import(authGuiUrl)).default; + await authGui( + cliArgs['puter-backend'] as string | undefined, + ); + } catch (e) { + console.log( + '[server] could not start AuthMe browser flow:', + (e as Error).message, + ); + } + } else if ( + this.#config.env === 'dev' && + !cfg.no_browser_launch + ) { + // Auto-launch the browser on dev boot (matches v1 + // WebServerService). Opt out via `no_browser_launch: true`. try { const openModule = await import('open'); await openModule.default(liveUrl);