Files
puter/extensions/appTelemetry.ts
T
Ron Hernaus d2fee51844
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled
Support PostgreSQL database backend (#3167)
* feat(database): add postgres database client

Adds the PostgreSQL database client, native bootstrap migration, SQL preparation helpers, and database config/factory wiring.\n\nRefs #3165.

* feat(database): make backend queries postgres-aware

Updates runtime SQL call sites for database-specific booleans, identifiers, insert-ignore, upserts, JSON extraction, intervals, and Postgres insert ids.\n\nRefs #3165.

* test(database): cover postgres client behavior

Adds unit coverage for SQL preparation, factory selection, write-result mapping, and transaction rollback/commit ordering, plus an env-gated PostgreSQL integration flow.\n\nRefs #3165.

* docs(self-hosting): document postgres database setup

Adds PostgreSQL configuration examples and migration path guidance for self-hosted deployments.\n\nRefs #3165.

* fix: harden postgres oidc tests

* fix(postgres): normalize query results and SQL prep

* fix(user): preserve normalized cache booleans

* test(postgres): run integration coverage with pgmock

* tests: add way to run all tests with postgres though slow

Also adding note that postgres is not in active use so might not work out the box

---------

Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
2026-05-28 14:12:17 -07:00

132 lines
4.1 KiB
TypeScript

import type { Request, Response } from 'express';
import { Context } from '@heyputer/backend/src/core';
import { HttpError } from '@heyputer/backend/src/core/http';
import { extension } from '@heyputer/backend/src/extensions';
const clients = extension.import('client');
const stores = extension.import('store');
const services = extension.import('service');
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
const MAX_OFFSET = 100_000;
const parseIntParam = (
value: unknown,
{
key,
min,
max,
fallback,
}: { key: string; min: number; max: number; fallback: number },
): number => {
if (value === undefined || value === null) return fallback;
const parsed =
typeof value === 'number'
? value
: typeof value === 'string' && value.trim() !== ''
? Number(value)
: NaN;
if (
!Number.isFinite(parsed) ||
!Number.isInteger(parsed) ||
parsed < min ||
parsed > max
) {
throw new HttpError(
400,
`${key} must be an integer between ${min} and ${max}`,
);
}
return parsed;
};
export const handleAppTelemetryUsers = async (
req: Request,
res: Response,
): Promise<void> => {
const { app_uuid } = req.query as Record<string, string>;
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
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,
});
const app = await stores.app.getByUid(app_uuid);
if (!app) throw new HttpError(404, 'App not found');
// `apps-of-user:<uuid>: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 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');
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<string, unknown>).id, safeLimit, safeOffset],
);
res.json(
(users as Array<{ username: string; uuid: string }>).map((e) => ({
user: e.username,
user_uuid: e.uuid,
})),
);
};
export const handleAppTelemetryUserCount = async (
req: Request,
res: Response,
): Promise<void> => {
const { app_uuid } = req.query as Record<string, string>;
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 [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<string, unknown>).id],
)) as Array<{ n: number }>;
res.json({ count: row?.n ?? 0 });
};
extension.get(
'/app-telemetry/users',
{ subdomain: 'api', requireAuth: true },
handleAppTelemetryUsers,
);
extension.get(
'/app-telemetry/user-count',
{ subdomain: 'api', requireAuth: true },
handleAppTelemetryUserCount,
);