mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4bb57bb79 | ||
|
|
861cf5efb8 | ||
|
|
cf34f9fea9 | ||
|
|
92a6d30e26 | ||
|
|
23e8705b03 | ||
|
|
3240a4670a | ||
|
|
3bf83d40e9 | ||
|
|
c3368f553e | ||
|
|
27def94d8b | ||
|
|
2c139929ac | ||
|
|
b957f797da | ||
|
|
393500d565 | ||
|
|
a108837e82 | ||
|
|
859e88753f | ||
|
|
5160b44c4b | ||
|
|
17a9485fb2 | ||
|
|
d3c44f3d6c | ||
|
|
d49ba5281d | ||
|
|
c107c80bd8 | ||
|
|
cf90e775bf | ||
|
|
0a46096c41 | ||
|
|
289a44ae79 | ||
|
|
827e9bc343 | ||
|
|
f2ffaeb823 | ||
|
|
6f594f4b60 | ||
|
|
326be042f1 | ||
|
|
5b824fb745 | ||
|
|
cf6cb126b5 | ||
|
|
302df1edd2 | ||
|
|
e212509ed3 | ||
|
|
d3e3755d6d | ||
|
|
948ce6e529 | ||
|
|
d117dc231e | ||
|
|
eefa53e5bd | ||
|
|
db89fbfc52 | ||
|
|
97ec5d115c | ||
|
|
722a6cf50f | ||
|
|
d039dcfee5 | ||
|
|
d09aa11f2e | ||
|
|
8a2264546d | ||
|
|
0801e1dc44 | ||
|
|
a04d5fa5e0 | ||
|
|
e1280e8cc7 | ||
|
|
70884c2e30 | ||
|
|
917e95e3d7 | ||
|
|
27222bbb0d | ||
|
|
3720034464 | ||
|
|
de6cab1f9a | ||
|
|
2d1fbc2611 | ||
|
|
0afc70218f | ||
|
|
8d3abbc5f0 | ||
|
|
7ec14bc7db | ||
|
|
2843010a97 | ||
|
|
ad4e21ee61 | ||
|
|
4de39ab6f2 | ||
|
|
fbb646b8eb | ||
|
|
46f75ecd6f | ||
|
|
f68caa3bbd | ||
|
|
0109bf5b72 | ||
|
|
c69fb52d5c | ||
|
|
3a26f1f1c2 | ||
|
|
f1b94a5b69 | ||
|
|
e3fce9191a | ||
|
|
2c0a1b9bbb | ||
|
|
0d7417f858 | ||
|
|
2eee098c66 | ||
|
|
890d4b3c16 | ||
|
|
af9a7c6345 | ||
|
|
6fff1fd75f | ||
|
|
e6b7b08bda | ||
|
|
f4c3cbd53d | ||
|
|
c0c2f35266 | ||
|
|
ccea980753 | ||
|
|
2bddebd576 | ||
|
|
b9866ddbeb | ||
|
|
6fc79a7184 | ||
|
|
50039571b6 | ||
|
|
1216f20567 | ||
|
|
0752ea761c | ||
|
|
98461088f8 | ||
|
|
d4a3224aa3 | ||
|
|
197ce6faff | ||
|
|
c98e293879 | ||
|
|
ebbe382596 | ||
|
|
01d28eb2c5 | ||
|
|
7d1c2e7f0a | ||
|
|
22b499af20 | ||
|
|
7208f86723 | ||
|
|
2974a8b01c | ||
|
|
7f05a707bd | ||
|
|
1d056253ef | ||
|
|
a22dc5facb | ||
|
|
aeafd4ac79 | ||
|
|
14861df6b2 | ||
|
|
34a7595a6d | ||
|
|
b2108c0216 | ||
|
|
5a5a449210 |
No files matched your search
@@ -53,7 +53,7 @@ The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts))
|
||||
|
||||
- **Modules:** We transpile and build as needed — write ES modules, not CommonJS.
|
||||
- **TypeScript preferred for new files.** Existing JS is fine; convert opportunistically when you're already touching a file.
|
||||
- **Reuse types before inventing them.** Search for an existing type first; extend it if close. Only define a new type when nothing fits.
|
||||
- **Reuse types and code before adding duplicates.** Before defining a type, search for an existing one; extend it if close. Before writing a helper or repeating logic, search for an existing utility, helper, or implementation and reuse or extend it. Only add a new type or helper when nothing suitable exists.
|
||||
- **Make new types findable.** Co-locate them with the layer/module that owns them, export from the obvious entry point, and use a descriptive `PascalCase` name. Don't hide types in random files where future readers won't grep them.
|
||||
- **Naming:** `camelCase` for variables/functions, `PascalCase` for classes and for files containing a class (`AuthService.ts`, `KVStoreDriver.ts`).
|
||||
|
||||
@@ -64,6 +64,8 @@ Keep comments light. Prefer self-documenting code — clear names, small functio
|
||||
- The _why_ is non-obvious (a hidden constraint, a workaround, a subtle invariant).
|
||||
- A non-trivial usage detail would otherwise trip the next reader.
|
||||
|
||||
If comments need to be more than a line, its probably too long, and when it does need multiline comments, use multiline `/** ... */` JSDoc style for consistency. Use `//` for single-line comments.
|
||||
|
||||
Don't restate what the code already says. Don't write comments that reference the current task or PR or version of something — those rot.
|
||||
|
||||
**No ticket references in code or test comments.** Don't write `PUT-1234`, `AUTH-3`, `GUI-2`, `// fix for FOO-99`, `// per ROLLOUT-1`, or section headers like `// -- PUT-1020: ... --`. The ticket exists in git history and the issue tracker; the code should describe the _why_ in domain terms ("logout cascade transparently invalidates...") not project-management terms ("the cascade from AUTH-3..."). Same rule for test-block names and section dividers — describe what's being tested, not which ticket asked for it.
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"default_user_group": "78b1b1dd-c959-44d2-b02c-8735671f9997",
|
||||
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
|
||||
"storage_capacity": 104857600,
|
||||
"disable_user_signup": false,
|
||||
"strict_email_verification_required": false,
|
||||
"gui_assets_root": "./src/gui",
|
||||
"puterjs_root": "./src/puter-js/dist",
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
// `dev` opens a browser on boot, skips blocked-email checks, and runs the
|
||||
// dev-time webpack watcher; `prod` serves pre-built bundles.
|
||||
"env": "dev",
|
||||
// Console output format. `json` replaces the global console so every call
|
||||
// emits one structured JSON line (level, timestamp, msg, and the active
|
||||
// request's trace id) — one event per call, so a line-oriented log
|
||||
// collector can't split stack traces across events and level filtering
|
||||
// works. Unset (the default) leaves console output human-readable.
|
||||
"log_format": "text",
|
||||
"version": "0.0.0",
|
||||
// Stable identity for this server node — used by pager alerts and
|
||||
// graceful-shutdown coordination.
|
||||
@@ -75,6 +81,9 @@
|
||||
"url_signature_secret": "change-me",
|
||||
"cookie_name": "puter_auth_token",
|
||||
"min_pass_length": 6,
|
||||
// When true, anonymous users must log in instead of creating temp or
|
||||
// permanent accounts.
|
||||
"disable_user_signup": false,
|
||||
"allow_system_login": false,
|
||||
"strict_email_verification_required": false,
|
||||
"captcha": {
|
||||
|
||||
+10
-1
@@ -135,7 +135,7 @@ Replace `puter.localhost`, `site.puter.localhost`, `host.puter.localhost`, `dev.
|
||||
Why these knobs:
|
||||
|
||||
- `jwt_secret` + `jwt_secret_v2` — Puter signs new auth tokens with `jwt_secret_v2` (v2 token format, `kid: 'v2'` JWT header). `jwt_secret` is verify-only and lets tokens minted before this version (cookies still in users' browsers, stored API tokens) keep working. `allow_v1_tokens: true` keeps that fallback enabled. Fresh installs can leave it as-is; future versions will flip the flag to `false` and retire `jwt_secret` entirely once legacy tokens have drained.
|
||||
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start=gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
|
||||
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start:gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
|
||||
- `database.migrationPaths` — Puter applies the bundled MySQL/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts.
|
||||
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
|
||||
- `dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable.
|
||||
@@ -379,6 +379,15 @@ Built-in proof-of-work captcha — no external service needed.
|
||||
|
||||
`difficulty` is one of `easy` / `medium` / `hard`.
|
||||
|
||||
### Disable new signups
|
||||
|
||||
Force visitors to log in with an existing account instead of creating a
|
||||
temporary or permanent one.
|
||||
|
||||
```json
|
||||
"disable_user_signup": true
|
||||
```
|
||||
|
||||
### Block disposable email TLDs
|
||||
|
||||
Only enforced when `env: "prod"`.
|
||||
|
||||
+59
-141
@@ -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<string, unknown> = {}): 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<void>,
|
||||
) => 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 = <T>(
|
||||
actor: { user: { uuid: string; id?: number } } | undefined,
|
||||
fn: () => Promise<T>,
|
||||
) => runWithContext({ actor }, fn);
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
driver = (server.drivers as unknown as Record<string, AppTelemetryDriver>)
|
||||
.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:<owner>: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:<owner>: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);
|
||||
});
|
||||
});
|
||||
+88
-80
@@ -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<void> => {
|
||||
const { app_uuid } = req.query as Record<string, string>;
|
||||
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<Array<{ user: string; user_uuid: 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 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:<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 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:<uuid>: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<string, unknown>).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<void> => {
|
||||
const { app_uuid } = req.query as Record<string, string>;
|
||||
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<number> {
|
||||
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<string, unknown>).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);
|
||||
@@ -132,9 +132,41 @@ describe('installedApps extension — handleInstalledApps', () => {
|
||||
expect(list[0].uid).toBe(app!.uid);
|
||||
expect(list[0].name).toBe(app!.name);
|
||||
expect(list[0].title).toBe(app!.title);
|
||||
// index_url is required so the dashboard can derive a hostname title
|
||||
// for anonymous (app-…) apps.
|
||||
expect(list[0].index_url).toBe(app!.index_url);
|
||||
expect(Object.prototype.hasOwnProperty.call(list[0], 'iconUrl')).toBe(
|
||||
true,
|
||||
);
|
||||
// An owned app is not external, and the raw owner id must not leak.
|
||||
expect(list[0].external).toBe(false);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('flags apps with no owner_user_id as external', async () => {
|
||||
const user = await seedUser();
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
// createFromOrigin bootstraps an app with owner_user_id = null.
|
||||
const app = await server.stores.app.createFromOrigin(
|
||||
`app-${slug}`,
|
||||
`https://external-${slug}.example.com`,
|
||||
);
|
||||
await grantInstalled(app!.id as number, user.id as number);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleInstalledApps(makeReq({}), res),
|
||||
);
|
||||
|
||||
const list = captured.body as Array<Record<string, unknown>>;
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].external).toBe(true);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('clamps page/limit to safe ranges (page>=1, 1<=limit<=100)', async () => {
|
||||
|
||||
@@ -49,6 +49,8 @@ export const handleInstalledApps = async (
|
||||
apps.title,
|
||||
apps.description,
|
||||
apps.icon,
|
||||
apps.index_url,
|
||||
apps.owner_user_id,
|
||||
MIN(perm.dt) AS installed_at
|
||||
FROM apps
|
||||
LEFT JOIN user_to_app_permissions AS perm ON apps.id = perm.app_id
|
||||
@@ -62,10 +64,18 @@ export const handleInstalledApps = async (
|
||||
|
||||
const apiBaseUrl = extension.config.api_base_url as string | undefined;
|
||||
res.json(
|
||||
installedApps.map((app) => ({
|
||||
...app,
|
||||
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
|
||||
})),
|
||||
installedApps.map((app) => {
|
||||
// An app with no owner_user_id (null/empty) isn't owned by a Puter
|
||||
// user — it's an "external" app. Derive a flag and don't leak the
|
||||
// raw owner id to the client.
|
||||
const { owner_user_id, ...rest } = app;
|
||||
const external = owner_user_id == null || owner_user_id === '';
|
||||
return {
|
||||
...rest,
|
||||
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
|
||||
external,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.email).toBe(user.email);
|
||||
expect(body.is_temp).toBe(false);
|
||||
expect(body.oidc_only).toBe(false);
|
||||
// is_user_token is present (true) for user actors.
|
||||
expect(body.is_user_token).toBe(true);
|
||||
// `directories` is only sent to user actors — confirm it’s present.
|
||||
expect(body.directories).toBeDefined();
|
||||
// taskbar_items is only sent to user actors.
|
||||
@@ -146,6 +148,8 @@ describe('whoami extension — handleWhoami', () => {
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.app_name).toBe('app-test-actor');
|
||||
// is_user_token is stripped for app actors.
|
||||
expect(body.is_user_token).toBeUndefined();
|
||||
expect(body.desktop_bg_url).toBeUndefined();
|
||||
expect(body.desktop_bg_color).toBeUndefined();
|
||||
expect(body.desktop_bg_fit).toBeUndefined();
|
||||
@@ -154,6 +158,49 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.directories).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redacts tmp_password from metadata for user actors', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
tmp_password: 'bootstrap-secret',
|
||||
hasDevAccountAccess: true,
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
const metadata = body.metadata as Record<string, unknown>;
|
||||
expect(metadata.tmp_password).toBeUndefined();
|
||||
// Other metadata keys still reach the user's own client.
|
||||
expect(metadata.hasDevAccountAccess).toBe(true);
|
||||
expect(body.hasDevAccountAccess).toBe(true);
|
||||
});
|
||||
|
||||
it('never sends user metadata to app actors', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
tmp_password: 'bootstrap-secret',
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{
|
||||
actor: {
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.metadata).toBeUndefined();
|
||||
expect(JSON.stringify(body)).not.toContain('bootstrap-secret');
|
||||
});
|
||||
|
||||
it('marks the user as oidc_only when password is null', async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const oidcUser = await server.stores.user.create({
|
||||
|
||||
@@ -65,6 +65,9 @@ export const handleWhoami = async (
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = user.metadata ? { ...user.metadata } : user.metadata;
|
||||
if (metadata) delete metadata.tmp_password;
|
||||
|
||||
const details: Record<string, unknown> = {
|
||||
username: user.username,
|
||||
uuid: user.uuid,
|
||||
@@ -79,6 +82,7 @@ export const handleWhoami = async (
|
||||
desktop_bg_color: user.desktop_bg_color,
|
||||
desktop_bg_fit: user.desktop_bg_fit,
|
||||
is_temp: user.password === null && user.email === null,
|
||||
is_user_token: true,
|
||||
oidc_only: oidcOnly,
|
||||
taskbar_items: isUser
|
||||
? await getTaskbarItems(
|
||||
@@ -97,7 +101,7 @@ export const handleWhoami = async (
|
||||
human_readable_age: user.timestamp
|
||||
? timeago.format(new Date(user.timestamp as string))
|
||||
: null,
|
||||
metadata: user.metadata,
|
||||
metadata,
|
||||
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
|
||||
};
|
||||
|
||||
@@ -160,6 +164,8 @@ export const handleWhoami = async (
|
||||
delete details.desktop_bg_color;
|
||||
delete details.desktop_bg_fit;
|
||||
delete details.human_readable_age;
|
||||
delete details.is_user_token;
|
||||
delete details.metadata;
|
||||
}
|
||||
|
||||
if (actor.app) {
|
||||
|
||||
Generated
+4802
-5043
File diff suppressed because it is too large.
Load diff
+9
-4
@@ -46,7 +46,7 @@
|
||||
"scripts": {
|
||||
"test:backend": "npm run setupExtensions && vitest run --config src/backend/vitest.config.ts ",
|
||||
"test:backend:postgres": "npm run setupExtensions && PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
|
||||
"start=gui": "nodemon --exec \"node dev-server.js\" ",
|
||||
"start:gui": "nodemon --exec \"node dev-server.js\" ",
|
||||
"start": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js",
|
||||
"prestart": "npm run setupExtensions && npm run build:ts",
|
||||
"dev": "npm start",
|
||||
@@ -78,15 +78,20 @@
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.6",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"@google/genai": false,
|
||||
"[email protected].1": true
|
||||
"[email protected].1": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { createPool, type Pool } from 'mysql2';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
import { SQLBatcher } from './SQLBatcher.js';
|
||||
import { splitMysqlStatements } from './splitMysqlStatements.js';
|
||||
@@ -135,6 +136,10 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
// Query interface
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// The db.* spans measure the logical query, including time queued in
|
||||
// the SQLBatcher — the mysql2 auto-instrumentation only sees the
|
||||
// coalesced multi-statement flush, so per-query latency lives here.
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -144,6 +149,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
|
||||
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
|
||||
override async pread(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -153,6 +159,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -170,6 +177,9 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(
|
||||
entries: { statement: string; values: unknown[] }[],
|
||||
): Promise<void> {
|
||||
@@ -195,6 +205,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { Pool, type PoolConfig, type QueryResult } from 'pg';
|
||||
import { Span } from '../../util/span.js';
|
||||
import {
|
||||
AbstractDatabaseClient,
|
||||
type BatchEntry,
|
||||
@@ -214,6 +215,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -222,6 +224,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return normalizePostgresRows(result);
|
||||
}
|
||||
|
||||
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
|
||||
override async pread(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -230,6 +233,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return normalizePostgresRows(result);
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -238,6 +242,9 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return mapPostgresWriteResult(result);
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(entries: BatchEntry[]): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
@@ -258,6 +265,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
|
||||
@@ -21,6 +21,7 @@ import { existsSync, mkdirSync, readFileSync } from 'fs';
|
||||
import { basename, dirname, extname, join, resolve } from 'path';
|
||||
import { createContext, runInContext } from 'vm';
|
||||
import type { IConfig } from '../../types';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
|
||||
const MIGRATIONS_DIR = resolve(__dirname, './migrations/sqlite');
|
||||
@@ -88,6 +89,11 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[52, ['0057_add_user_reputation.sql']],
|
||||
[53, ['0058_add_phone_verification.sql']],
|
||||
[54, ['0059_add_card_verification.sql']],
|
||||
[55, ['0060_add_card_fingerprint.sql']],
|
||||
[56, ['0061_add_suspended_at.sql']],
|
||||
[57, ['0062_blocked-app-origins.sql']],
|
||||
[58, ['0063_add_suspended_reason.sql']],
|
||||
[59, ['0064_abuse-moderation-events.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
@@ -131,6 +137,7 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
// Query interface
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -151,6 +158,7 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -167,6 +175,9 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(
|
||||
entries: { statement: string; values: unknown[] }[],
|
||||
): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- 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/>.
|
||||
|
||||
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
|
||||
-- the account was suspended, as unix seconds (NULL while not suspended) — the
|
||||
-- timestamp sibling of the boolean `suspended` flag, indexed so the
|
||||
-- signup-abuse harness can count an IP's recently-suspended accounts.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations); the index add is guarded against
|
||||
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'suspended_at', '`suspended_at` bigint DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_suspended_at_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_suspended_at'
|
||||
) THEN
|
||||
ALTER TABLE `user` ADD INDEX `idx_user_suspended_at` (`suspended_at`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_suspended_at_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- 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/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062.
|
||||
-- An app whose `index_url` host (or a request origin) matches an entry is
|
||||
-- denied access to Puter resources: it cannot obtain an app token and
|
||||
-- already-issued app tokens are rejected on each request. `include_subdomains
|
||||
-- = 1` also blocks every subdomain of `domain`. Enforced in AuthService via
|
||||
-- AppOriginBlocklistService.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`domain` VARCHAR(255) NOT NULL,
|
||||
`include_subdomains` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`reason` TEXT DEFAULT NULL,
|
||||
`created_by` VARCHAR(255) DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_blocked_app_origins_domain` (`domain`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- 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/>.
|
||||
|
||||
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
|
||||
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
|
||||
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
|
||||
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations).
|
||||
|
||||
CALL _puter_add_col('user', 'suspended_reason', '`suspended_reason` VARCHAR(64) DEFAULT NULL');
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- 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/>.
|
||||
|
||||
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064.
|
||||
-- Used to measure the abuse system's false-positive rate: `unsuspend` (admin
|
||||
-- unblock) and `admin_create_user` are the false-positive signals; `suspend`
|
||||
-- gives the denominator. Preserves history the `user` suspension columns lose
|
||||
-- on unsuspend. `created_at` is unix seconds (matching `user.suspended_at`).
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`action` VARCHAR(32) NOT NULL,
|
||||
`target_user_id` BIGINT DEFAULT NULL,
|
||||
`target_username` VARCHAR(255) DEFAULT NULL,
|
||||
`admin_username` VARCHAR(255) DEFAULT NULL,
|
||||
`reason` TEXT DEFAULT NULL,
|
||||
`source` VARCHAR(64) DEFAULT NULL,
|
||||
`created_at` BIGINT NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_abuse_moderation_events_created_at` (`created_at`),
|
||||
KEY `idx_abuse_moderation_events_action` (`action`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- 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/>.
|
||||
|
||||
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
|
||||
-- the account was suspended, as unix seconds (NULL while not suspended) — the
|
||||
-- timestamp sibling of the boolean `suspended` flag, indexed so the
|
||||
-- signup-abuse harness can count an IP's recently-suspended accounts.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_at bigint;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON "user" (suspended_at);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- 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/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062 /
|
||||
-- MySQL mysql_mig_17 (which Postgres was originally missed for). An app whose
|
||||
-- `index_url` host (or a request origin) matches an entry is denied access to
|
||||
-- Puter resources. `include_subdomains = 1` also blocks every subdomain of
|
||||
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocked_app_origins (
|
||||
id SERIAL PRIMARY KEY,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
include_subdomains SMALLINT NOT NULL DEFAULT 0,
|
||||
reason TEXT DEFAULT NULL,
|
||||
created_by VARCHAR(255) DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_blocked_app_origins_domain
|
||||
ON blocked_app_origins (domain);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- 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/>.
|
||||
|
||||
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
|
||||
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
|
||||
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
|
||||
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_reason text;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- 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/>.
|
||||
|
||||
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064 /
|
||||
-- MySQL mysql_mig_19. Used to measure the abuse system's false-positive rate:
|
||||
-- `unsuspend` (admin unblock) and `admin_create_user` are the false-positive
|
||||
-- signals; `suspend` gives the denominator. Preserves history the `user`
|
||||
-- suspension columns lose on unsuspend. `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS abuse_moderation_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
target_user_id BIGINT DEFAULT NULL,
|
||||
target_username VARCHAR(255) DEFAULT NULL,
|
||||
admin_username VARCHAR(255) DEFAULT NULL,
|
||||
reason TEXT DEFAULT NULL,
|
||||
source VARCHAR(64) DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
|
||||
ON abuse_moderation_events (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
|
||||
ON abuse_moderation_events (action);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- 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/>.
|
||||
|
||||
-- When the account was suspended, as unix seconds; NULL while not suspended.
|
||||
-- The timestamp sibling of the existing boolean `suspended` flag, stamped by
|
||||
-- every suspension site. Indexed so the signup-abuse harness can count an IP's
|
||||
-- recently-suspended accounts (see extensions/abuse/v2 suspendedAccountsByIp).
|
||||
ALTER TABLE `user` ADD COLUMN `suspended_at` INTEGER DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON `user` (`suspended_at`);
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- 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/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. An app whose `index_url` host (or
|
||||
-- a request origin) matches an entry is denied access to Puter resources:
|
||||
-- it cannot obtain an app token and already-issued app tokens are rejected
|
||||
-- on each request. `include_subdomains = 1` also blocks every subdomain of
|
||||
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"domain" TEXT NOT NULL,
|
||||
"include_subdomains" INTEGER NOT NULL DEFAULT 0,
|
||||
"reason" TEXT DEFAULT NULL,
|
||||
"created_by" TEXT DEFAULT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_blocked_app_origins_domain`
|
||||
ON `blocked_app_origins` (`domain`);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- 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/>.
|
||||
|
||||
-- Why an account was suspended, NULL while not suspended. Companion to the
|
||||
-- boolean `suspended` flag and `suspended_at` timestamp. Constrained at the
|
||||
-- application layer to a fixed set of reasons (see extensions/admin
|
||||
-- suspension_reasons.js) — kept as free TEXT here so adding a reason later
|
||||
-- needs no migration.
|
||||
ALTER TABLE `user` ADD COLUMN `suspended_reason` TEXT DEFAULT NULL;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- 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/>.
|
||||
|
||||
-- Append-only log of admin moderation actions, used to measure the abuse
|
||||
-- system's false-positive rate: `unsuspend` (admin unblock) and
|
||||
-- `admin_create_user` (an account minted by an admin, typically to recover a
|
||||
-- legitimately-blocked signup) are the false-positive signals; `suspend` gives
|
||||
-- the denominator. Unlike the `suspended`/`suspended_at`/`suspended_reason`
|
||||
-- columns on `user` — which are cleared on unsuspend and so keep no history —
|
||||
-- this table preserves every event. `created_at` is unix seconds (matching
|
||||
-- `user.suspended_at`), stamped by the recorder in
|
||||
-- extensions/admin/moderation_events.js. Written best-effort at the admin
|
||||
-- suspend/unsuspend/create-user sites; surfaced on the /admin/abuse dashboard.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"action" TEXT NOT NULL, -- 'suspend' | 'unsuspend' | 'admin_create_user'
|
||||
"target_user_id" INTEGER DEFAULT NULL,
|
||||
"target_username" TEXT DEFAULT NULL,
|
||||
"admin_username" TEXT DEFAULT NULL, -- the acting admin (req.actor.user.username)
|
||||
"reason" TEXT DEFAULT NULL,
|
||||
"source" TEXT DEFAULT NULL, -- 'user_page' | 'email_hostname_blacklist' | 'create_user'
|
||||
"created_at" INTEGER NOT NULL -- unix seconds
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
|
||||
ON `abuse_moderation_events` (`created_at`);
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
|
||||
ON `abuse_moderation_events` (`action`);
|
||||
@@ -43,6 +43,7 @@ import { once } from 'node:events';
|
||||
import { Agent as httpsAgent } from 'node:https';
|
||||
import { HttpError } from '../../core/http';
|
||||
import type { IConfig, IDynamoConfig } from '../../types';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { PuterClient } from '../types';
|
||||
|
||||
const LOCAL_DYNAMO_MEMORY_PREFIX = ':memory:';
|
||||
@@ -151,6 +152,7 @@ export class DDBClient extends PuterClient {
|
||||
await this.#localInitPromise;
|
||||
}
|
||||
|
||||
@Span('ddb.get', (table: string) => ({ 'db.table': table }))
|
||||
async get<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
key: T,
|
||||
@@ -167,6 +169,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
|
||||
async put<T extends Record<string, unknown>>(table: string, item: T) {
|
||||
const command = new PutCommand({
|
||||
TableName: table,
|
||||
@@ -178,6 +181,9 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.batchGet', (params: unknown[]) => ({
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
async batchGet(
|
||||
params: { table: string; items: Record<string, unknown> }[],
|
||||
consistentRead = false,
|
||||
@@ -212,6 +218,9 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.batchPut', (params: unknown[]) => ({
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
async batchPut(params: { table: string; item: Record<string, unknown> }[]) {
|
||||
const consumedCapacityByTable = new Map<string, number>();
|
||||
if (params.length === 0) {
|
||||
@@ -318,6 +327,7 @@ export class DDBClient extends PuterClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('ddb.del', (table: string) => ({ 'db.table': table }))
|
||||
async del<T extends Record<string, unknown>>(table: string, key: T) {
|
||||
const command = new DeleteCommand({
|
||||
TableName: table,
|
||||
@@ -329,6 +339,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.query', (table: string) => ({ 'db.table': table }))
|
||||
async query<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
keys: T,
|
||||
@@ -383,6 +394,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.update', (table: string) => ({ 'db.table': table }))
|
||||
async update<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
key: T,
|
||||
|
||||
@@ -24,12 +24,20 @@ import type { IConfig } from '../../types';
|
||||
import { PuterClient } from '../types';
|
||||
import { EMAIL_TEMPLATES, type EmailTemplateName } from './templates';
|
||||
|
||||
// -- Types ------------------------------------------------------------
|
||||
/** Attachment shape passed through to the underlying transport. */
|
||||
export interface EmailAttachment {
|
||||
filename: string;
|
||||
content: Buffer | string;
|
||||
contentType?: string;
|
||||
encoding?: string;
|
||||
}
|
||||
|
||||
// nodemailer doesn't ship TS types, so declare the subset we use.
|
||||
interface NodemailerTransport {
|
||||
sendMail: (options: SendMailOptions) => Promise<unknown>;
|
||||
close?: () => void;
|
||||
/** Subset of the transport's send result callers may care about. */
|
||||
export interface SentMessageInfo {
|
||||
messageId?: string;
|
||||
accepted?: string[];
|
||||
rejected?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SendMailOptions {
|
||||
@@ -41,6 +49,7 @@ export interface SendMailOptions {
|
||||
html?: string;
|
||||
text?: string;
|
||||
replyTo?: string;
|
||||
attachments?: EmailAttachment[];
|
||||
}
|
||||
|
||||
export type EmailValidator = (email: string) => Promise<boolean> | boolean;
|
||||
@@ -100,7 +109,8 @@ const DOMAIN_ALIASES: Record<string, string> = {
|
||||
* - Policy + extensible validation (via `validate`)
|
||||
*/
|
||||
export class EmailClient extends PuterClient {
|
||||
private transport: NodemailerTransport | null = null;
|
||||
private transport: ReturnType<typeof nodemailer.createTransport> | null =
|
||||
null;
|
||||
private compiledTemplates: Partial<
|
||||
Record<EmailTemplateName, CompiledTemplate>
|
||||
> = {};
|
||||
@@ -123,8 +133,7 @@ export class EmailClient extends PuterClient {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.transport = nodemailer.createTransport(emailConf as any);
|
||||
this.transport = nodemailer.createTransport(emailConf);
|
||||
console.log('[email] transport configured');
|
||||
}
|
||||
|
||||
@@ -159,21 +168,30 @@ export class EmailClient extends PuterClient {
|
||||
/**
|
||||
* Raw send — bypasses the template system. Useful for one-off
|
||||
* admin emails that don't warrant a named template.
|
||||
*
|
||||
* Returns the transport's send result, or `null` when no transport
|
||||
* is configured (the send is a no-op in that case — callers that
|
||||
* must not silently drop mail should check `isConfigured` first).
|
||||
*/
|
||||
async sendRaw(options: SendMailOptions): Promise<void> {
|
||||
async sendRaw(options: SendMailOptions) {
|
||||
if (!this.transport) {
|
||||
console.warn(
|
||||
'[email] attempted to send email without transport. If you need to send email, configure an SMTP transport in your config file (see docs for details). Email content:',
|
||||
options,
|
||||
);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
await this.transport.sendMail({
|
||||
from: options.from ?? this.defaultFrom(),
|
||||
return await this.transport.sendMail({
|
||||
...options,
|
||||
from: options.from ?? this.defaultFrom(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether an SMTP transport is configured (sends are no-ops otherwise). */
|
||||
get isConfigured(): boolean {
|
||||
return this.transport !== null;
|
||||
}
|
||||
|
||||
// -- Public API: clean / validate ---------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { extensionStore } from '../../extensions';
|
||||
import { withSpan } from '../../util/span.js';
|
||||
import { PuterClient } from '../types';
|
||||
import {
|
||||
EventListener,
|
||||
@@ -99,26 +100,34 @@ export class EventClient extends PuterClient {
|
||||
data: EventMap[T],
|
||||
meta: EventMetadata,
|
||||
) {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const matchKey = (
|
||||
i === parts.length - 1
|
||||
? key
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`
|
||||
) as ListenKey;
|
||||
const extensionListeners = extensionStore.events[matchKey];
|
||||
const listeners = (this.#eventListeners[matchKey] || []).concat(
|
||||
extensionListeners || [],
|
||||
);
|
||||
if (!listeners) continue;
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
} catch (e) {
|
||||
console.error('Error in event listener for event', key, e);
|
||||
// Spanned because callers block on listeners — this is where time
|
||||
// spent in extension hooks (e.g. `ip.validate`) gets attributed.
|
||||
return withSpan('event.emitAndWait', { 'event.key': key }, async () => {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const matchKey = (
|
||||
i === parts.length - 1
|
||||
? key
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`
|
||||
) as ListenKey;
|
||||
const extensionListeners = extensionStore.events[matchKey];
|
||||
const listeners = (this.#eventListeners[matchKey] || []).concat(
|
||||
extensionListeners || [],
|
||||
);
|
||||
if (!listeners) continue;
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'Error in event listener for event',
|
||||
key,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +155,19 @@ export class EventClient extends PuterClient {
|
||||
this.#eventListeners[key] ?? (this.#eventListeners[key] = []);
|
||||
listeners.push(callback as EventListener);
|
||||
}
|
||||
off<P extends ListenKey>(
|
||||
key: P,
|
||||
callback: (
|
||||
key: MatchingEvents<P>,
|
||||
data: EventMap[MatchingEvents<P>],
|
||||
meta: EventMetadata,
|
||||
) => Promise<void> | void,
|
||||
) {
|
||||
const listeners = this.#eventListeners[key];
|
||||
if (!listeners) return;
|
||||
const idx = listeners.indexOf(callback as EventListener);
|
||||
if (idx !== -1) listeners.splice(idx, 1);
|
||||
}
|
||||
async #emitEvent<T extends keyof EventMap>(
|
||||
listener: EventListener,
|
||||
key: T,
|
||||
|
||||
@@ -120,6 +120,21 @@ export type EventMap = {
|
||||
actor: unknown;
|
||||
result: { allowed: boolean; reason?: string; error?: string };
|
||||
};
|
||||
// Emitted after an app token is granted (get-user-app-token). The app-side
|
||||
// analogue of `site.htmlServed` — the external-app abuse scanner
|
||||
// (extensions/subdomainAbuse/appScan.ts) listens to screenshot + classify
|
||||
// external `index_url`s that Puter never serves itself.
|
||||
'puter.app.authenticated': {
|
||||
app_uid?: string;
|
||||
user_id?: number | null;
|
||||
app?: {
|
||||
id?: number;
|
||||
uid?: string;
|
||||
index_url?: string | null;
|
||||
owner_user_id?: number | null;
|
||||
name?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
// ---- Auth / signup ----
|
||||
'puter.signup.validate': {
|
||||
@@ -137,9 +152,8 @@ export type EventMap = {
|
||||
* the request for log / support correlation.
|
||||
*/
|
||||
trail_id?: string;
|
||||
/** Device signals forwarded verbatim from the signup request body. */
|
||||
/** Device signal forwarded verbatim from the signup request body. */
|
||||
fingerprint?: string | null;
|
||||
dfp_telemetry_id?: string | null;
|
||||
/** Set by the abuse harness — require SMS phone verification post-signup. */
|
||||
requires_phone_verification?: boolean;
|
||||
/** Set by the abuse harness — require card verification post-signup. */
|
||||
@@ -237,6 +251,7 @@ export type EventMap = {
|
||||
fingerprint: string | null;
|
||||
funding: string | null;
|
||||
country: string | null;
|
||||
customer_id: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'user.card-verified': {
|
||||
@@ -245,6 +260,7 @@ export type EventMap = {
|
||||
fingerprint: string | null;
|
||||
funding: string | null;
|
||||
country: string | null;
|
||||
customer_id: string | null;
|
||||
};
|
||||
'user.username-changed': {
|
||||
user_id: number;
|
||||
@@ -376,6 +392,8 @@ export type EventMap = {
|
||||
// normalized path: `route.<method>.<path>.before|after|error|reject`. Same
|
||||
// wildcard + veto semantics as the driver lifecycle above.
|
||||
[K in `route.${string}`]: RouteLifecycleEvent;
|
||||
} & {
|
||||
[K in `pubsub.login.${string}`]: { authtoken: string };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -79,12 +79,28 @@ describe('PreludeClient', () => {
|
||||
expect(init.headers.Authorization).toBe('Bearer sk_test');
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
target: { type: 'phone_number', value: '+14155550123' },
|
||||
// Defaults to RCS (cheaper); Prelude falls back to SMS.
|
||||
options: { code_size: 6, preferred_channel: 'rcs' },
|
||||
// Defaults to RCS (cheaper); Prelude falls back to SMS. locale is
|
||||
// hardcoded to en-US so the message text is always English.
|
||||
options: { code_size: 6, preferred_channel: 'rcs', locale: 'en-US' },
|
||||
signals: { ip: '203.0.113.7' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the delivery channel sequence Prelude reports', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
okJson({
|
||||
id: 'vrf_1',
|
||||
status: 'success',
|
||||
channels: ['whatsapp', 'sms'],
|
||||
}),
|
||||
);
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
const res = await client.createVerification('+14155550123');
|
||||
|
||||
expect(res.channels).toEqual(['whatsapp', 'sms']);
|
||||
});
|
||||
|
||||
it('forwards device_id and user_agent signals when supplied', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
@@ -114,6 +130,32 @@ describe('PreludeClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards dispatch_id as a top-level field, not inside signals', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
await client.createVerification('+14155550123', {
|
||||
ip: '203.0.113.7',
|
||||
dispatch_id: 'd1f5e9a0-0000-4000-8000-000000000000',
|
||||
});
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.dispatch_id).toBe('d1f5e9a0-0000-4000-8000-000000000000');
|
||||
expect(body.signals).toEqual({ ip: '203.0.113.7' });
|
||||
expect(body.signals).not.toHaveProperty('dispatch_id');
|
||||
});
|
||||
|
||||
it('omits dispatch_id when not supplied', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
await client.createVerification('+14155550123', { ip: '203.0.113.7' });
|
||||
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).not.toHaveProperty(
|
||||
'dispatch_id',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes a configured template_id + sender_id + preferred channel', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = new PreludeClient({
|
||||
@@ -131,6 +173,7 @@ describe('PreludeClient', () => {
|
||||
expect(JSON.parse(init.body).options).toEqual({
|
||||
code_size: 6,
|
||||
preferred_channel: 'sms', // config override beats the rcs default
|
||||
locale: 'en-US',
|
||||
template_id: 'tmpl_puter',
|
||||
sender_id: 'Puter',
|
||||
});
|
||||
|
||||
@@ -42,6 +42,12 @@ export type PreludeChannel =
|
||||
| 'viber'
|
||||
| 'zalo'
|
||||
| 'telegram';
|
||||
/**
|
||||
* Channels Prelude reports in a verification's `channels` array — the ordered
|
||||
* delivery sequence, first entry first. Superset of `PreludeChannel`: 'silent'
|
||||
* and 'voice' can appear as delivery methods but can't be preferred.
|
||||
*/
|
||||
export type PreludeDeliveryChannel = PreludeChannel | 'silent' | 'voice';
|
||||
/**
|
||||
* Default per-SMS cost ceiling (EUR). Countries whose Prelude SMS rate exceeds
|
||||
* this — or that have no SMS channel — are not offered phone verification. The
|
||||
@@ -117,25 +123,19 @@ export class PreludeClient extends PuterClient {
|
||||
return price.sms <= this.maxSmsCostEur;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or retry) a verification: Prelude sends an OTP to `target`.
|
||||
* @param target E.164 phone number, e.g. "+14155550123".
|
||||
* @param signals Optional anti-fraud signals. The more we forward, the
|
||||
* better Prelude's fraud model scores the attempt (per their docs IP +
|
||||
* device + platform are the highest-value). We pass what the backend
|
||||
* already has: the request `ip`, the client `device_id` (ThumbmarkJS
|
||||
* hash), and the `user_agent` (Prelude infers platform/model/OS from it).
|
||||
* Richer client-collected signals require Prelude's frontend SDK, which
|
||||
* produces a `dispatch_id` the backend would forward here.
|
||||
*/
|
||||
async createVerification(
|
||||
target: string,
|
||||
signals: {
|
||||
ip?: string;
|
||||
device_id?: string;
|
||||
user_agent?: string;
|
||||
dispatch_id?: string;
|
||||
} = {},
|
||||
): Promise<{ id?: string; status: PreludeCreateStatus }> {
|
||||
): Promise<{
|
||||
id?: string;
|
||||
status: PreludeCreateStatus;
|
||||
channels?: PreludeDeliveryChannel[];
|
||||
}> {
|
||||
// Match the 6-box code UI (UIWindowPhoneVerificationRequired). Without
|
||||
// code_size Prelude uses the dashboard default (4). preferred_channel
|
||||
// prioritizes RCS (cheaper); Prelude falls back to SMS when unavailable.
|
||||
@@ -144,6 +144,7 @@ export class PreludeClient extends PuterClient {
|
||||
preferred_channel:
|
||||
this.config.prelude?.preferredChannel ??
|
||||
DEFAULT_PREFERRED_CHANNEL,
|
||||
locale: 'en-US',
|
||||
};
|
||||
// Branding lives in the Prelude dashboard (the message text is a
|
||||
// template); these just select a Puter-branded template / sender when
|
||||
@@ -163,9 +164,12 @@ export class PreludeClient extends PuterClient {
|
||||
if (signals.device_id) sig.device_id = signals.device_id;
|
||||
if (signals.user_agent) sig.user_agent = signals.user_agent;
|
||||
if (Object.keys(sig).length > 0) body.signals = sig;
|
||||
// dispatch_id is a top-level field, not a member of `signals`.
|
||||
if (signals.dispatch_id) body.dispatch_id = signals.dispatch_id;
|
||||
return this.#post('/verification', body) as Promise<{
|
||||
id?: string;
|
||||
status: PreludeCreateStatus;
|
||||
channels?: PreludeDeliveryChannel[];
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
@@ -586,6 +586,24 @@ describe('AuthController.handleSignup', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects brand-new temp signups when registration is disabled', async () => {
|
||||
const authConfig = server.controllers.auth.config as {
|
||||
disable_user_signup?: boolean;
|
||||
};
|
||||
const prev = authConfig.disable_user_signup;
|
||||
authConfig.disable_user_signup = true;
|
||||
try {
|
||||
await expect(
|
||||
controller.handleSignup(makeReq({ is_temp: true }), makeRes()),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'signup_disabled',
|
||||
});
|
||||
} finally {
|
||||
authConfig.disable_user_signup = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('emits puter.signup.success on successful signup', async () => {
|
||||
const baseline = heardSignupSuccess.length;
|
||||
const username = `s_${uniq()}`;
|
||||
@@ -607,9 +625,93 @@ describe('AuthController.handleSignup', () => {
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('still allows claiming a pseudo-user row when registration is disabled', async () => {
|
||||
const authConfig = server.controllers.auth.config as {
|
||||
disable_user_signup?: boolean;
|
||||
};
|
||||
const prev = authConfig.disable_user_signup;
|
||||
authConfig.disable_user_signup = true;
|
||||
try {
|
||||
const targetEmail = `disabled_claim_${uniq()}@test.local`;
|
||||
const placeholder = await server.stores.user.create({
|
||||
username: `placeholder_${uniq()}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: targetEmail,
|
||||
clean_email: targetEmail,
|
||||
email_confirmed: 0,
|
||||
} as never);
|
||||
|
||||
const res = makeRes();
|
||||
await controller.handleSignup(
|
||||
makeReq({
|
||||
username: `claim_${uniq()}`,
|
||||
email: targetEmail,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(isCompleteLoginResponse(res.body)).toBe(true);
|
||||
const claimed = await server.stores.user.getById(placeholder.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(claimed!.username).not.toBe(placeholder.username);
|
||||
} finally {
|
||||
authConfig.disable_user_signup = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not reveal existing usernames or emails when registration is disabled', async () => {
|
||||
const username = `taken_${uniq()}`;
|
||||
const email = `${username}@test.local`;
|
||||
await controller.handleSignup(
|
||||
makeReq({ username, email, password: 'correct-horse-battery' }),
|
||||
makeRes(),
|
||||
);
|
||||
|
||||
const authConfig = server.controllers.auth.config as {
|
||||
disable_user_signup?: boolean;
|
||||
};
|
||||
const prev = authConfig.disable_user_signup;
|
||||
authConfig.disable_user_signup = true;
|
||||
try {
|
||||
// Taken username → the generic 403, not the duplicate error.
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
makeReq({
|
||||
username,
|
||||
email: `fresh_${uniq()}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'signup_disabled',
|
||||
});
|
||||
// Taken (non-claimable) email → same generic 403.
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
makeReq({
|
||||
username: `fresh_${uniq()}`,
|
||||
email,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'signup_disabled',
|
||||
});
|
||||
} finally {
|
||||
authConfig.disable_user_signup = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- Signup device signals (fingerprint + dfp_telemetry_id) --
|
||||
// -- Signup device signal (fingerprint) --
|
||||
|
||||
describe('AuthController.handleSignup device signals', () => {
|
||||
const uniq = () => Math.random().toString(36).slice(2, 10);
|
||||
@@ -631,11 +733,10 @@ describe('AuthController.handleSignup device signals', () => {
|
||||
(evt) => (evt as { username?: string }).username === username,
|
||||
);
|
||||
|
||||
it('forwards fingerprint and dfp_telemetry_id verbatim to validate and success events', async () => {
|
||||
it('forwards fingerprint verbatim to validate and success events', async () => {
|
||||
const username = `fp_${uniq()}`;
|
||||
const baseline = heardSignupSuccess.length;
|
||||
const fingerprint = 'Fp_abc.123-XYZ';
|
||||
const dfpTelemetryId = 'tel_id-456';
|
||||
|
||||
const seen = await captureValidateEvents(async () => {
|
||||
const res = makeRes();
|
||||
@@ -645,7 +746,6 @@ describe('AuthController.handleSignup device signals', () => {
|
||||
email: `${username}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
fingerprint,
|
||||
dfp_telemetry_id: dfpTelemetryId,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
@@ -654,7 +754,6 @@ describe('AuthController.handleSignup device signals', () => {
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].fingerprint).toBe(fingerprint);
|
||||
expect(seen[0].dfp_telemetry_id).toBe(dfpTelemetryId);
|
||||
|
||||
const successes = successEventsFor(baseline, username);
|
||||
expect(successes).toHaveLength(1);
|
||||
@@ -662,7 +761,7 @@ describe('AuthController.handleSignup device signals', () => {
|
||||
expect(successes[0].is_temp).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts boundary-length values (128-char fingerprint, 64-char dfp_telemetry_id)', async () => {
|
||||
it('accepts a boundary-length 128-char fingerprint', async () => {
|
||||
const username = `fp_${uniq()}`;
|
||||
const res = makeRes();
|
||||
await controller.handleSignup(
|
||||
@@ -671,14 +770,13 @@ describe('AuthController.handleSignup device signals', () => {
|
||||
email: `${username}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
fingerprint: 'f'.repeat(128),
|
||||
dfp_telemetry_id: 'd'.repeat(64),
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(isCompleteLoginResponse(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults both fields to null on the validate event when absent', async () => {
|
||||
it('defaults the fingerprint to null on the validate event when absent', async () => {
|
||||
const username = `fp_${uniq()}`;
|
||||
const baseline = heardSignupSuccess.length;
|
||||
|
||||
@@ -699,7 +797,6 @@ describe('AuthController.handleSignup device signals', () => {
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].fingerprint).toBeNull();
|
||||
expect(seen[0].dfp_telemetry_id).toBeNull();
|
||||
|
||||
const successes = successEventsFor(baseline, username);
|
||||
expect(successes).toHaveLength(1);
|
||||
@@ -707,7 +804,7 @@ describe('AuthController.handleSignup device signals', () => {
|
||||
expect(successes[0].is_temp).toBe(false);
|
||||
});
|
||||
|
||||
it('treats empty-string fingerprint and dfp_telemetry_id as absent', async () => {
|
||||
it('treats an empty-string fingerprint as absent', async () => {
|
||||
const username = `fp_${uniq()}`;
|
||||
|
||||
const seen = await captureValidateEvents(async () => {
|
||||
@@ -718,7 +815,6 @@ describe('AuthController.handleSignup device signals', () => {
|
||||
email: `${username}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
fingerprint: '',
|
||||
dfp_telemetry_id: '',
|
||||
}),
|
||||
res,
|
||||
);
|
||||
@@ -728,7 +824,6 @@ describe('AuthController.handleSignup device signals', () => {
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].fingerprint).toBeNull();
|
||||
expect(seen[0].dfp_telemetry_id).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a non-string fingerprint with 400 and fires no success event', async () => {
|
||||
@@ -770,37 +865,6 @@ describe('AuthController.handleSignup device signals', () => {
|
||||
expect(heardSignupSuccess.length).toBe(baseline);
|
||||
});
|
||||
|
||||
it('rejects a dfp_telemetry_id longer than 64 characters with 400 and fires no success event', async () => {
|
||||
const username = `fp_${uniq()}`;
|
||||
const baseline = heardSignupSuccess.length;
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
makeReq({
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
dfp_telemetry_id: 'd'.repeat(65),
|
||||
}),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(heardSignupSuccess.length).toBe(baseline);
|
||||
});
|
||||
|
||||
it('rejects a non-string dfp_telemetry_id with 400', async () => {
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
makeReq({
|
||||
username: `fp_${uniq()}`,
|
||||
email: `${uniq()}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
dfp_telemetry_id: { nested: true },
|
||||
}),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('temp-user signup reports is_temp true and carries the fingerprint on the success event', async () => {
|
||||
const baseline = heardSignupSuccess.length;
|
||||
const res = makeRes();
|
||||
@@ -1902,6 +1966,104 @@ describe('AuthController.handleSendConfirmPhone validation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('attaches a support error_id to a Prelude block', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
await withPrelude(
|
||||
stubPrelude({
|
||||
createVerification: vi.fn(async () => ({ status: 'blocked' })),
|
||||
}),
|
||||
async () => {
|
||||
await expect(
|
||||
controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
fields: { error_id: expect.any(String) },
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('attaches a support error_id when the Prelude request throws', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
await withPrelude(
|
||||
stubPrelude({
|
||||
createVerification: vi.fn(async () => {
|
||||
throw new Error('network down');
|
||||
}),
|
||||
}),
|
||||
async () => {
|
||||
await expect(
|
||||
controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
fields: { error_id: expect.any(String) },
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('persists the send failure to KV under the error_id with a 7-day expiry', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const kvSet = vi.spyOn(server.stores.kv, 'set');
|
||||
try {
|
||||
await withPrelude(
|
||||
stubPrelude({
|
||||
createVerification: vi.fn(async () => {
|
||||
throw new Error('network down');
|
||||
}),
|
||||
}),
|
||||
async () => {
|
||||
let thrown: HttpError | undefined;
|
||||
try {
|
||||
await controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
makeRes(),
|
||||
);
|
||||
} catch (e) {
|
||||
thrown = e as HttpError;
|
||||
}
|
||||
const errorId = thrown!.fields!.error_id as string;
|
||||
|
||||
const { res: record } = await server.stores.kv.get({
|
||||
key: `sms-send-error:${errorId}`,
|
||||
});
|
||||
expect(record).toMatchObject({
|
||||
reason: 'prelude_request_failed',
|
||||
status: 502,
|
||||
user_id: user.id,
|
||||
user_uid: user.uuid,
|
||||
detail: 'network down',
|
||||
t: expect.any(Number),
|
||||
});
|
||||
|
||||
const errorSet = kvSet.mock.calls.find(([arg]) =>
|
||||
(arg as { key?: string }).key?.startsWith(
|
||||
'sms-send-error:',
|
||||
),
|
||||
);
|
||||
const { expireAt } = errorSet![0] as {
|
||||
expireAt: number;
|
||||
};
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
expect(expireAt).toBeGreaterThan(
|
||||
nowSec + 7 * 24 * 60 * 60 - 60,
|
||||
);
|
||||
expect(expireAt).toBeLessThanOrEqual(
|
||||
nowSec + 7 * 24 * 60 * 60,
|
||||
);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
kvSet.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('forwards ip, device fingerprint, and user-agent to Prelude as signals', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
const createVerification = vi.fn(async () => ({ status: 'success' }));
|
||||
@@ -1941,6 +2103,38 @@ describe('AuthController.handleSendConfirmPhone validation', () => {
|
||||
user_agent: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the delivery channel Prelude picked so the client can point at the right app', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
await withPrelude(
|
||||
stubPrelude({
|
||||
createVerification: vi.fn(async () => ({
|
||||
status: 'success',
|
||||
channels: ['whatsapp', 'sms'],
|
||||
})),
|
||||
}),
|
||||
async () => {
|
||||
const res = makeRes();
|
||||
await controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
res,
|
||||
);
|
||||
expect(res.body).toMatchObject({ channel: 'whatsapp' });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('omits `channel` when Prelude reports no delivery sequence', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
await withPrelude(stubPrelude(), async () => {
|
||||
const res = makeRes();
|
||||
await controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
res,
|
||||
);
|
||||
expect(res.body).not.toHaveProperty('channel');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController phone verification — staging & reuse', () => {
|
||||
@@ -2240,6 +2434,47 @@ describe('AuthController.handleConfirmPhone', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('awaits the user.phone-verified listeners (emitAndWait, not fire-and-forget)', async () => {
|
||||
// The carrier-based card-verification waiver (abuse extension) listens
|
||||
// on user.phone-verified and must clear the card gate BEFORE confirm
|
||||
// responds — so the event has to be awaited, not fire-and-forget.
|
||||
const { actor } = await makeUserAndActor({
|
||||
requires_phone_verification: 1,
|
||||
phone: '+14155550123',
|
||||
});
|
||||
const emit = vi.fn();
|
||||
const emitAndWait = vi.fn(async () => {});
|
||||
const ctrl = controller as { clients: { event: unknown } };
|
||||
const realEvent = ctrl.clients.event;
|
||||
ctrl.clients.event = { emit, emitAndWait };
|
||||
try {
|
||||
await withPrelude(
|
||||
stubPrelude({
|
||||
checkVerification: vi.fn(async () => ({
|
||||
status: 'success',
|
||||
})),
|
||||
}),
|
||||
async () => {
|
||||
await controller.handleConfirmPhone(
|
||||
makeReq({ code: '123456' }, { actor }),
|
||||
makeRes(),
|
||||
);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
ctrl.clients.event = realEvent;
|
||||
}
|
||||
expect(emitAndWait).toHaveBeenCalledWith(
|
||||
'user.phone-verified',
|
||||
expect.objectContaining({ phone: '+14155550123' }),
|
||||
expect.anything(),
|
||||
);
|
||||
const fireAndForget = emit.mock.calls.filter(
|
||||
(c) => c[0] === 'user.phone-verified',
|
||||
);
|
||||
expect(fireAndForget).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('throws 502 when the upstream check fails', async () => {
|
||||
const { actor } = await makeUserAndActor({
|
||||
requires_phone_verification: 1,
|
||||
@@ -2489,6 +2724,312 @@ describe('AuthController.handleCardVerificationConfirm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController SMS → card fallback', () => {
|
||||
const stubPrelude = (over: Record<string, unknown> = {}) => ({
|
||||
isConfigured: () => true,
|
||||
isCountrySupported: () => true,
|
||||
defaultCountry: 'US',
|
||||
createVerification: vi.fn(async () => ({ status: 'success' })),
|
||||
...over,
|
||||
});
|
||||
const withPrelude = async (
|
||||
prelude: unknown,
|
||||
fn: () => Promise<void>,
|
||||
): Promise<void> => {
|
||||
const ctrl = controller as { clients: { prelude: unknown } };
|
||||
const real = ctrl.clients.prelude;
|
||||
ctrl.clients.prelude = prelude;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
ctrl.clients.prelude = real;
|
||||
}
|
||||
};
|
||||
const withFallbackConfig = async (
|
||||
value: unknown,
|
||||
fn: () => Promise<void>,
|
||||
): Promise<void> => {
|
||||
const cfg = (controller as { config: Record<string, unknown> }).config;
|
||||
const prev = cfg.phone_verification_card_fallback;
|
||||
cfg.phone_verification_card_fallback = value;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
cfg.phone_verification_card_fallback = prev;
|
||||
}
|
||||
};
|
||||
// Drive the attempt counter directly so the threshold is deterministic
|
||||
// (the handler keys it the same way: `phone-verify-attempts:<id>`).
|
||||
const seedAttempts = (userId: number, attempts: number) =>
|
||||
server.stores.kv.incr({
|
||||
key: `phone-verify-attempts:${userId}`,
|
||||
pathAndAmountMap: { attempts },
|
||||
});
|
||||
// Stamp the eligibility flag the card endpoints check, the same way a
|
||||
// threshold-crossing send does (`card-fallback-open:<id>`).
|
||||
const openFallback = (userId: number) =>
|
||||
server.stores.kv.set({
|
||||
key: `card-fallback-open:${userId}`,
|
||||
value: true,
|
||||
});
|
||||
|
||||
it('offers the fallback on send once the attempt threshold is reached', async () => {
|
||||
const { actor } = await makeUserAndActor({
|
||||
requires_phone_verification: 1,
|
||||
});
|
||||
// No after_attempts → exercises the default threshold of 2.
|
||||
await withFallbackConfig({ enabled: true }, async () => {
|
||||
await withPrelude(stubPrelude(), async () => {
|
||||
const first = makeRes();
|
||||
await controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
first,
|
||||
);
|
||||
// First attempt is below the threshold — no offer yet.
|
||||
expect(first.body).toEqual({});
|
||||
|
||||
const second = makeRes();
|
||||
await controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
second,
|
||||
);
|
||||
expect(second.body).toEqual({
|
||||
card_fallback_available: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('never offers the fallback on send when disabled', async () => {
|
||||
const { actor } = await makeUserAndActor({
|
||||
requires_phone_verification: 1,
|
||||
});
|
||||
await withFallbackConfig(
|
||||
{ enabled: false, after_attempts: 1 },
|
||||
async () => {
|
||||
await withPrelude(stubPrelude(), async () => {
|
||||
const res = makeRes();
|
||||
await controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
res,
|
||||
);
|
||||
expect(res.body).toEqual({});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('flags the fallback on a Prelude block once eligible', async () => {
|
||||
const { actor } = await makeUserAndActor({
|
||||
requires_phone_verification: 1,
|
||||
});
|
||||
await withFallbackConfig(
|
||||
{ enabled: true, after_attempts: 1 },
|
||||
async () => {
|
||||
await withPrelude(
|
||||
stubPrelude({
|
||||
createVerification: vi.fn(async () => ({
|
||||
status: 'blocked',
|
||||
})),
|
||||
}),
|
||||
async () => {
|
||||
await expect(
|
||||
controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
fields: { card_fallback_available: true },
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('lets card setup proceed past the phone gate once eligible', async () => {
|
||||
const { user, actor } = await makeUserAndActor({
|
||||
requires_card_verification: 1,
|
||||
requires_phone_verification: 1,
|
||||
phone: '+14155550123',
|
||||
});
|
||||
await openFallback(user.id);
|
||||
await withFallbackConfig(
|
||||
{ enabled: true },
|
||||
async () => {
|
||||
const res = makeRes();
|
||||
await withCardSetupOverride(
|
||||
(data) => {
|
||||
data.enabled = true;
|
||||
data.client_secret = 'seti_secret';
|
||||
data.publishable_key = 'pk_test';
|
||||
},
|
||||
() =>
|
||||
controller.handleCardVerificationSetup(
|
||||
makeReq({}, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
client_secret: 'seti_secret',
|
||||
publishable_key: 'pk_test',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('still 409s card setup when no send has opened the fallback', async () => {
|
||||
const { user, actor } = await makeUserAndActor({
|
||||
requires_card_verification: 1,
|
||||
requires_phone_verification: 1,
|
||||
phone: '+14155550123',
|
||||
});
|
||||
// Counter above the threshold but no flag: eligibility is the flag a
|
||||
// threshold-crossing send stamps, never the raw counter.
|
||||
await seedAttempts(user.id, 5);
|
||||
await withFallbackConfig(
|
||||
{ enabled: true, after_attempts: 3 },
|
||||
async () => {
|
||||
await expect(
|
||||
controller.handleCardVerificationSetup(
|
||||
makeReq({}, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 409 });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('send crossing the threshold opens card setup end-to-end', async () => {
|
||||
const { actor } = await makeUserAndActor({
|
||||
requires_card_verification: 1,
|
||||
requires_phone_verification: 1,
|
||||
});
|
||||
await withFallbackConfig(
|
||||
{ enabled: true, after_attempts: 1 },
|
||||
async () => {
|
||||
await withPrelude(stubPrelude(), async () => {
|
||||
const sendRes = makeRes();
|
||||
await controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
sendRes,
|
||||
);
|
||||
expect(sendRes.body).toEqual({
|
||||
card_fallback_available: true,
|
||||
});
|
||||
});
|
||||
const res = makeRes();
|
||||
await withCardSetupOverride(
|
||||
(data) => {
|
||||
data.enabled = true;
|
||||
data.client_secret = 'seti_secret';
|
||||
data.publishable_key = 'pk_test';
|
||||
},
|
||||
() =>
|
||||
controller.handleCardVerificationSetup(
|
||||
makeReq({}, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
client_secret: 'seti_secret',
|
||||
publishable_key: 'pk_test',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps after_attempts to the send route rate limit', async () => {
|
||||
const { user, actor } = await makeUserAndActor({
|
||||
requires_phone_verification: 1,
|
||||
});
|
||||
// 9 prior attempts + this send = 10, the route limit. A threshold of
|
||||
// 50 could never be crossed, so it clamps down and the offer opens.
|
||||
await seedAttempts(user.id, 9);
|
||||
await withFallbackConfig(
|
||||
{ enabled: true, after_attempts: 50 },
|
||||
async () => {
|
||||
await withPrelude(stubPrelude(), async () => {
|
||||
const res = makeRes();
|
||||
await controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
res,
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
card_fallback_available: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('clears BOTH gates when the fallback card verifies', async () => {
|
||||
const { user, actor } = await makeUserAndActor({
|
||||
requires_card_verification: 1,
|
||||
requires_phone_verification: 1,
|
||||
phone: '+14155550123',
|
||||
});
|
||||
await openFallback(user.id);
|
||||
await withFallbackConfig(
|
||||
{ enabled: true },
|
||||
async () => {
|
||||
const res = makeRes();
|
||||
await withCardConfirmOverride(
|
||||
(data) => {
|
||||
data.enabled = true;
|
||||
data.verified = true;
|
||||
},
|
||||
() =>
|
||||
controller.handleCardVerificationConfirm(
|
||||
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toMatchObject({
|
||||
card_verified: true,
|
||||
phone_verified: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
const after = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.requires_card_verification).toBe(false);
|
||||
expect(after!.requires_phone_verification).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the phone gate via card even when card was not required', async () => {
|
||||
const { user, actor } = await makeUserAndActor({
|
||||
requires_phone_verification: 1,
|
||||
phone: '+14155550123',
|
||||
});
|
||||
await openFallback(user.id);
|
||||
await withFallbackConfig(
|
||||
{ enabled: true },
|
||||
async () => {
|
||||
const res = makeRes();
|
||||
await withCardConfirmOverride(
|
||||
(data) => {
|
||||
data.enabled = true;
|
||||
data.verified = true;
|
||||
},
|
||||
() =>
|
||||
controller.handleCardVerificationConfirm(
|
||||
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toMatchObject({ phone_verified: true });
|
||||
},
|
||||
);
|
||||
const after = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.requires_phone_verification).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController.handleConfirmEmail', () => {
|
||||
it('throws 400 when code is missing', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { v4 as uuidv4, validate as validateUuid } from 'uuid';
|
||||
import validator from 'validator';
|
||||
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
import type { HttpErrorOptions } from '../../core/http/HttpError.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { antiCsrf } from '../../core/http/middleware/antiCsrf.js';
|
||||
import { generateCaptcha } from '../../core/http/middleware/captcha.js';
|
||||
@@ -47,8 +48,8 @@ import {
|
||||
} from '../../services/auth/OTPUtil.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
|
||||
import { parsePhone } from '../../util/phone.js';
|
||||
import { generate_identifier } from '../../util/identifier.js';
|
||||
import { parsePhone } from '../../util/phone.js';
|
||||
import { getTaskbarItems } from '../../util/taskbarItems.js';
|
||||
import {
|
||||
generateDefaultFsentries,
|
||||
@@ -59,7 +60,21 @@ import { PuterController } from '../types.js';
|
||||
const USERNAME_REGEX = /^\w{1,}$/;
|
||||
const USERNAME_MAX_LENGTH = 45;
|
||||
const FINGERPRINT_MAX_LENGTH = 128;
|
||||
const DFP_TELEMETRY_ID_MAX_LENGTH = 64;
|
||||
const DISPATCH_ID_MAX_LENGTH = 128;
|
||||
// Default SMS send attempts before the card fallback opens.
|
||||
const DEFAULT_CARD_FALLBACK_ATTEMPTS = 2;
|
||||
// /send-confirm-phone route rate limit. Also caps the fallback's
|
||||
// `after_attempts`: requests past the route limit are rejected in middleware
|
||||
// and never reach the attempt counter, so a higher threshold could never be
|
||||
// crossed.
|
||||
const SEND_PHONE_RATE_LIMIT = 10;
|
||||
const SEND_PHONE_RATE_WINDOW_MS = 60 * 60_000;
|
||||
// Once the threshold is crossed the fallback stays open this long, so the
|
||||
// user can finish the card flow without racing the attempt counter's expiry.
|
||||
const CARD_FALLBACK_OPEN_TTL_SECONDS = 24 * 60 * 60;
|
||||
// How long a failed-SMS-send record stays readable by its error_id — long
|
||||
// enough to cover the typical support round-trip.
|
||||
const SMS_SEND_ERROR_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
const RESERVED_USERNAMES = new Set([
|
||||
'admin',
|
||||
'administrator',
|
||||
@@ -98,6 +113,67 @@ const RESERVED_USERNAMES = new Set([
|
||||
*/
|
||||
@Controller('')
|
||||
export class AuthController extends PuterController {
|
||||
@Post('/login/wait', {
|
||||
subdomain: ['api'],
|
||||
rateLimit: [
|
||||
// A client will make a request to this every 10 seconds while waiting for the login to complete, so we allow a higher limit than the main /login endpoint.
|
||||
{ scope: 'login-wait', limit: 100, window: 15 * 60_000, key: 'ip' },
|
||||
],
|
||||
})
|
||||
async loginWait(req: Request, res: Response) {
|
||||
const { session } = req.body;
|
||||
// validate uuid to prevent ultra long key or listening on pubsub.login.*
|
||||
if (!session || !validateUuid(session)) {
|
||||
throw new HttpError(400, 'session is required.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const { resolve, promise } = Promise.withResolvers<void>();
|
||||
|
||||
let token: string | null = null;
|
||||
const listener = (_key: string, value: { authtoken: string }) => {
|
||||
token = value.authtoken;
|
||||
resolve();
|
||||
};
|
||||
this.clients.event.on(`pubsub.login.${session}`, listener);
|
||||
|
||||
const timeout = new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, 10000),
|
||||
);
|
||||
await Promise.race([promise, timeout]);
|
||||
this.clients.event.off(`pubsub.login.${session}`, listener);
|
||||
if (!token) {
|
||||
throw new HttpError(408, 'Request timeout.', {
|
||||
legacyCode: 'request_timeout',
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
auth_token: token,
|
||||
});
|
||||
}
|
||||
@Post('/login/set', {
|
||||
subdomain: ['api'],
|
||||
})
|
||||
async loginSet(req: Request, res: Response) {
|
||||
const { session, auth_token } = req.body;
|
||||
if (!session || !auth_token || !validateUuid(session)) {
|
||||
throw new HttpError(400, 'session and auth_token are required.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
this.clients.event.emit(
|
||||
`pubsub.login.${session}`,
|
||||
{
|
||||
authtoken: auth_token,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// -- Login -------------------------------------------------------
|
||||
|
||||
@Post('/login', {
|
||||
@@ -378,11 +454,10 @@ export class AuthController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional device signals (browser fingerprint hash, DFP telemetry
|
||||
// id). Core only enforces shape and forwards the values verbatim —
|
||||
// signup-abuse policy built on them lives in extensions. Checked
|
||||
// before the reauth short-circuit so a malformed value is rejected
|
||||
// on every /signup path.
|
||||
// Optional device signal (browser fingerprint hash). Core only enforces
|
||||
// shape and forwards the value verbatim — signup-abuse policy built on it
|
||||
// lives in extensions. Checked before the reauth short-circuit so a
|
||||
// malformed value is rejected on every /signup path.
|
||||
if (body.fingerprint !== undefined && body.fingerprint !== null) {
|
||||
if (typeof body.fingerprint !== 'string')
|
||||
throw new HttpError(400, 'fingerprint must be a string.', {
|
||||
@@ -395,25 +470,9 @@ export class AuthController extends PuterController {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (
|
||||
body.dfp_telemetry_id !== undefined &&
|
||||
body.dfp_telemetry_id !== null
|
||||
) {
|
||||
if (typeof body.dfp_telemetry_id !== 'string')
|
||||
throw new HttpError(400, 'dfp_telemetry_id must be a string.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
if (body.dfp_telemetry_id.length > DFP_TELEMETRY_ID_MAX_LENGTH)
|
||||
throw new HttpError(
|
||||
400,
|
||||
`dfp_telemetry_id cannot be longer than ${DFP_TELEMETRY_ID_MAX_LENGTH} characters.`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
// Empty strings are treated as absent — a signal that wasn't
|
||||
// collected, not a malformed request.
|
||||
const fingerprint: string | null = body.fingerprint || null;
|
||||
const dfp_telemetry_id: string | null = body.dfp_telemetry_id || null;
|
||||
|
||||
// Temp-user reauth short-circuit: when an existing temp user is
|
||||
// forced through the reauth flow, the GUI re-submits /signup with
|
||||
@@ -527,6 +586,31 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
// Signup-disabled gate. Runs before the duplicate checks so a
|
||||
// disabled endpoint doesn't reveal which usernames or emails
|
||||
// exist. Claiming a pre-existing placeholder row is still
|
||||
// allowed, so permanent signups look the email up first.
|
||||
if (this.config.disable_user_signup) {
|
||||
let claimable = false;
|
||||
if (!is_temp) {
|
||||
const existing =
|
||||
(await this.stores.user.getByEmail(body.email)) ??
|
||||
(await this.stores.user.getByCleanEmail(
|
||||
cleanEmail(body.email),
|
||||
));
|
||||
claimable = Boolean(
|
||||
existing &&
|
||||
!existing.email_confirmed &&
|
||||
existing.password === null,
|
||||
);
|
||||
}
|
||||
if (!claimable) {
|
||||
throw new HttpError(403, 'User registration is disabled.', {
|
||||
legacyCode: 'signup_disabled',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate username check
|
||||
if (await this.stores.user.getByUsername(body.username)) {
|
||||
throw new HttpError(
|
||||
@@ -605,7 +689,6 @@ export class AuthController extends PuterController {
|
||||
code: null,
|
||||
user_agent: req?.headers?.['user-agent'] ?? null,
|
||||
fingerprint,
|
||||
dfp_telemetry_id,
|
||||
// Populated by the abuse extension's v2 harness; persisted to the
|
||||
// user row below so the signup-time reputation is referable later.
|
||||
reputation: null as number | null,
|
||||
@@ -746,7 +829,6 @@ export class AuthController extends PuterController {
|
||||
user_agent: req.headers?.['user-agent'],
|
||||
origin: req.headers?.origin,
|
||||
fingerprint,
|
||||
dfp_telemetry_id,
|
||||
},
|
||||
signup_ip: clientIp,
|
||||
signup_ip_forwarded: proxyIpChain,
|
||||
@@ -1037,14 +1119,176 @@ export class AuthController extends PuterController {
|
||||
|
||||
// -- Phone verification (SMS via Prelude) ------------------------
|
||||
|
||||
/**
|
||||
* Build the error thrown when a verification SMS can't be sent (a delivery
|
||||
* failure, or a refused/blocked send). Mints a short `error_id`, writes a
|
||||
* 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.
|
||||
*/
|
||||
private async smsSendError(
|
||||
statusCode: number,
|
||||
clientMessage: string,
|
||||
reason: string,
|
||||
ctx: {
|
||||
userId?: number;
|
||||
userUid?: string;
|
||||
country?: string;
|
||||
detail?: unknown;
|
||||
},
|
||||
options: HttpErrorOptions = {},
|
||||
): Promise<HttpError> {
|
||||
const errorId = uuidv4();
|
||||
const detail =
|
||||
ctx.detail instanceof Error ? ctx.detail.message : ctx.detail;
|
||||
console.warn(
|
||||
`[send-confirm-phone] send_failed error_id=${errorId} ` +
|
||||
`reason=${reason} status=${statusCode} ` +
|
||||
`user_id=${ctx.userId ?? ''} user_uid=${ctx.userUid ?? ''} ` +
|
||||
`country=${ctx.country ?? ''}` +
|
||||
(detail ? ` detail=${JSON.stringify(String(detail))}` : ''),
|
||||
);
|
||||
// Best-effort: the record backs a support lookup, so a KV failure
|
||||
// must never mask the error actually being reported.
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await this.stores.kv.set({
|
||||
key: `sms-send-error:${errorId}`,
|
||||
value: {
|
||||
reason,
|
||||
status: statusCode,
|
||||
user_id: ctx.userId ?? null,
|
||||
user_uid: ctx.userUid ?? null,
|
||||
country: ctx.country ?? null,
|
||||
detail: detail != null ? String(detail) : null,
|
||||
t: now,
|
||||
},
|
||||
expireAt: now + SMS_SEND_ERROR_TTL_SECONDS,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[send-confirm-phone] error-record store failed:', e);
|
||||
}
|
||||
return new HttpError(statusCode, clientMessage, {
|
||||
...options,
|
||||
fields: { ...options.fields, error_id: errorId },
|
||||
});
|
||||
}
|
||||
|
||||
// -- SMS-to-card fallback -----------------------------------------
|
||||
//
|
||||
// Once a user has made enough SMS send attempts in the rate-limit window
|
||||
// without getting through, they can verify a card instead to clear the
|
||||
// phone gate. Off unless config enables it.
|
||||
//
|
||||
// Two KV keys: a short-lived counter tied to the send rate-limit window
|
||||
// triggers the fallback, and a longer-lived "open" flag holds eligibility
|
||||
// once the threshold is crossed. The card endpoints check only the flag —
|
||||
// deriving eligibility from the raw counter would let it expire while the
|
||||
// user is mid-way through the card flow. Every KV failure fails closed
|
||||
// (fallback unavailable), never open.
|
||||
|
||||
private cardFallbackConfig(): { enabled: boolean; afterAttempts: number } {
|
||||
const cfg = this.config.phone_verification_card_fallback;
|
||||
const afterAttempts = Math.min(
|
||||
typeof cfg?.after_attempts === 'number' && cfg.after_attempts > 0
|
||||
? cfg.after_attempts
|
||||
: DEFAULT_CARD_FALLBACK_ATTEMPTS,
|
||||
SEND_PHONE_RATE_LIMIT,
|
||||
);
|
||||
return { enabled: Boolean(cfg?.enabled), afterAttempts };
|
||||
}
|
||||
|
||||
private phoneAttemptsKey(userId: number): string {
|
||||
return `phone-verify-attempts:${userId}`;
|
||||
}
|
||||
|
||||
private cardFallbackFlagKey(userId: number): string {
|
||||
return `card-fallback-open:${userId}`;
|
||||
}
|
||||
|
||||
// TTL ties the counter to the send rate-limit window, so it resets with it.
|
||||
private async bumpPhoneAttempts(userId: number): Promise<number> {
|
||||
try {
|
||||
const { res } = await this.stores.kv.incr({
|
||||
key: this.phoneAttemptsKey(userId),
|
||||
pathAndAmountMap: { attempts: 1 },
|
||||
expireAt:
|
||||
Math.floor(Date.now() / 1000) +
|
||||
SEND_PHONE_RATE_WINDOW_MS / 1000,
|
||||
});
|
||||
const count = (res as { attempts?: number } | null)?.attempts;
|
||||
return typeof count === 'number' ? count : 0;
|
||||
} catch (e) {
|
||||
console.warn('[send-confirm-phone] attempt-count bump failed:', e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count a send attempt and, once the threshold is crossed, stamp the
|
||||
* eligibility flag the card endpoints check. Returns whether the fallback
|
||||
* is open so send responses (success or 429) can advertise it.
|
||||
*/
|
||||
private async recordPhoneAttemptForFallback(user: {
|
||||
id: number;
|
||||
requires_phone_verification?: boolean | number | null;
|
||||
}): Promise<boolean> {
|
||||
const attempts = await this.bumpPhoneAttempts(user.id);
|
||||
const { enabled, afterAttempts } = this.cardFallbackConfig();
|
||||
const open =
|
||||
enabled &&
|
||||
Boolean(user.requires_phone_verification) &&
|
||||
attempts >= afterAttempts;
|
||||
if (open) {
|
||||
try {
|
||||
// Plain set, so each eligible attempt refreshes the window.
|
||||
await this.stores.kv.set({
|
||||
key: this.cardFallbackFlagKey(user.id),
|
||||
value: true,
|
||||
expireAt:
|
||||
Math.floor(Date.now() / 1000) +
|
||||
CARD_FALLBACK_OPEN_TTL_SECONDS,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[send-confirm-phone] fallback flag stamp failed:',
|
||||
e,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return open;
|
||||
}
|
||||
|
||||
private async isCardFallbackEligible(user: {
|
||||
id: number;
|
||||
requires_phone_verification?: boolean | number | null;
|
||||
}): Promise<boolean> {
|
||||
const { enabled } = this.cardFallbackConfig();
|
||||
if (!enabled || !user.requires_phone_verification) return false;
|
||||
try {
|
||||
const { res } = await this.stores.kv.get({
|
||||
key: this.cardFallbackFlagKey(user.id),
|
||||
});
|
||||
return res === true;
|
||||
} catch (e) {
|
||||
console.warn('[card-verification] fallback flag read failed:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/send-confirm-phone', {
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
rateLimit: {
|
||||
scope: 'send-confirm-phone',
|
||||
limit: 10,
|
||||
window: 60 * 60_000,
|
||||
limit: SEND_PHONE_RATE_LIMIT,
|
||||
window: SEND_PHONE_RATE_WINDOW_MS,
|
||||
key: 'user',
|
||||
},
|
||||
})
|
||||
@@ -1061,9 +1305,13 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'account_suspended',
|
||||
});
|
||||
if (!this.clients.prelude?.isConfigured())
|
||||
throw new HttpError(503, 'Phone verification is unavailable.', {
|
||||
legacyCode: 'service_unavailable' as never,
|
||||
});
|
||||
throw await this.smsSendError(
|
||||
503,
|
||||
'Phone verification is unavailable.',
|
||||
'prelude_not_configured',
|
||||
{ userId: user.id, userUid: user.uuid },
|
||||
{ legacyCode: 'service_unavailable' as never },
|
||||
);
|
||||
|
||||
// Parse to E.164 (Prelude's required form + the stored form) and the
|
||||
// country, so we can apply the per-country cost cap.
|
||||
@@ -1076,16 +1324,42 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
// Optional Prelude dispatch id (browser signals gathered by the JS
|
||||
// Signals SDK on the number-entry page). Shape-checked and forwarded
|
||||
// verbatim to Prelude; an empty / oversized / non-string value is just
|
||||
// dropped so a bad client signal never blocks a real verification.
|
||||
const rawDispatchId = req.body?.dispatch_id;
|
||||
const dispatchId =
|
||||
typeof rawDispatchId === 'string' &&
|
||||
rawDispatchId.length > 0 &&
|
||||
rawDispatchId.length <= DISPATCH_ID_MAX_LENGTH
|
||||
? rawDispatchId
|
||||
: undefined;
|
||||
|
||||
// Cost cap: skip countries with no SMS channel or rates above the cap
|
||||
// (see PreludeClient / countries.ts). Avoids paying exorbitant per-SMS
|
||||
// rates in low-revenue, high-fraud geographies.
|
||||
if (!this.clients.prelude.isCountrySupported(parsed.country))
|
||||
throw new HttpError(
|
||||
throw await this.smsSendError(
|
||||
400,
|
||||
'Phone verification is not available for this country.',
|
||||
'country_not_supported',
|
||||
{
|
||||
userId: user.id,
|
||||
userUid: user.uuid,
|
||||
country: parsed.country,
|
||||
},
|
||||
{ legacyCode: 'phone_country_not_supported' as never },
|
||||
);
|
||||
|
||||
// Counted before the abuse / Prelude checks so a blocked attempt still
|
||||
// counts toward the fallback threshold.
|
||||
const fallbackAvailable =
|
||||
await this.recordPhoneAttemptForFallback(user);
|
||||
const fallbackFields = fallbackAvailable
|
||||
? { card_fallback_available: true }
|
||||
: {};
|
||||
|
||||
// Abuse caps live ENTIRELY in a listening abuse extension, consulted
|
||||
// via `puter.phone-verification.check`. The backend ships no thresholds
|
||||
// or detection of its own (so none of it is readable in the open-source
|
||||
@@ -1117,14 +1391,23 @@ export class AuthController extends PuterController {
|
||||
// its meaning lives in the extension (which sets it) and the GUI (which
|
||||
// displays it), so no abuse semantics leak into the OSS repo.
|
||||
if (abuseCheck.allowed === false)
|
||||
throw new HttpError(
|
||||
throw await this.smsSendError(
|
||||
429,
|
||||
'Phone verification is unavailable for this number right now.',
|
||||
`not_allowed:${abuseCheck.reason ?? 'unspecified'}`,
|
||||
{
|
||||
userId: user.id,
|
||||
userUid: user.uuid,
|
||||
country: parsed.country,
|
||||
},
|
||||
{
|
||||
legacyCode: 'phone_verification_unavailable' as never,
|
||||
fields: abuseCheck.reason
|
||||
? { reason: abuseCheck.reason }
|
||||
: {},
|
||||
fields: {
|
||||
...fallbackFields,
|
||||
...(abuseCheck.reason
|
||||
? { reason: abuseCheck.reason }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1142,10 +1425,18 @@ export class AuthController extends PuterController {
|
||||
expireAt: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[send-confirm-phone] pending-store failed:', e);
|
||||
throw new HttpError(503, 'Could not start phone verification.', {
|
||||
legacyCode: 'service_unavailable' as never,
|
||||
});
|
||||
throw await this.smsSendError(
|
||||
503,
|
||||
'Could not start phone verification.',
|
||||
'pending_store_failed',
|
||||
{
|
||||
userId: user.id,
|
||||
userUid: user.uuid,
|
||||
country: parsed.country,
|
||||
detail: e,
|
||||
},
|
||||
{ legacyCode: 'service_unavailable' as never },
|
||||
);
|
||||
}
|
||||
|
||||
const ip = req.ip || req.socket?.remoteAddress || undefined;
|
||||
@@ -1153,6 +1444,10 @@ export class AuthController extends PuterController {
|
||||
typeof req.headers['user-agent'] === 'string'
|
||||
? req.headers['user-agent']
|
||||
: undefined;
|
||||
// First entry of Prelude's delivery sequence — where the code actually
|
||||
// went. Returned to the client so it can point the user at the right
|
||||
// app (e.g. "check WhatsApp" instead of "check your texts").
|
||||
let deliveryChannel: string | undefined;
|
||||
try {
|
||||
const result = await this.clients.prelude.createVerification(
|
||||
parsed.e164,
|
||||
@@ -1160,25 +1455,44 @@ export class AuthController extends PuterController {
|
||||
ip,
|
||||
device_id: req.deviceFingerprint ?? undefined,
|
||||
user_agent: userAgent,
|
||||
dispatch_id: dispatchId,
|
||||
},
|
||||
);
|
||||
deliveryChannel = result.channels?.[0];
|
||||
// Prelude rejected the attempt as abusive — surface as rate-limit.
|
||||
if (
|
||||
result.status === 'blocked' ||
|
||||
result.status === 'shadow_blocked'
|
||||
) {
|
||||
throw new HttpError(
|
||||
throw await this.smsSendError(
|
||||
429,
|
||||
'Phone verification is temporarily unavailable for this number.',
|
||||
{ legacyCode: 'too_many_requests' as never },
|
||||
`prelude_${result.status}`,
|
||||
{
|
||||
userId: user.id,
|
||||
userUid: user.uuid,
|
||||
country: parsed.country,
|
||||
},
|
||||
{
|
||||
legacyCode: 'too_many_requests' as never,
|
||||
fields: fallbackFields,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof HttpError) throw e;
|
||||
console.warn('[send-confirm-phone] createVerification failed:', e);
|
||||
throw new HttpError(502, 'Could not send verification code.', {
|
||||
legacyCode: 'upstream_error' as never,
|
||||
});
|
||||
throw await this.smsSendError(
|
||||
502,
|
||||
'Could not send verification code.',
|
||||
'prelude_request_failed',
|
||||
{
|
||||
userId: user.id,
|
||||
userUid: user.uuid,
|
||||
country: parsed.country,
|
||||
detail: e,
|
||||
},
|
||||
{ legacyCode: 'upstream_error' as never },
|
||||
);
|
||||
}
|
||||
|
||||
// Tell the abuse extension a code was actually sent, so it can bump its
|
||||
@@ -1199,7 +1513,10 @@ export class AuthController extends PuterController {
|
||||
} catch {
|
||||
// ignore — best-effort velocity signal
|
||||
}
|
||||
res.json({});
|
||||
res.json({
|
||||
...fallbackFields,
|
||||
...(deliveryChannel ? { channel: deliveryChannel } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('/confirm-phone', {
|
||||
@@ -1281,8 +1598,14 @@ export class AuthController extends PuterController {
|
||||
phone: pendingPhone,
|
||||
});
|
||||
|
||||
// Run the verified-event listeners synchronously (emitAndWait, not
|
||||
// fire-and-forget emit) so a carrier-based card-verification waiver in
|
||||
// the abuse extension lands BEFORE we broadcast "refresh" and respond —
|
||||
// otherwise the client re-fetches and still sees the card gate. Load-
|
||||
// bearing now; emitAndWait swallows listener errors, so this stays
|
||||
// best-effort and never blocks confirm on a listener.
|
||||
try {
|
||||
this.clients.event?.emit(
|
||||
await this.clients.event?.emitAndWait(
|
||||
'user.phone-verified' as never,
|
||||
{
|
||||
user_id: user.id,
|
||||
@@ -1292,7 +1615,7 @@ export class AuthController extends PuterController {
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
// ignore — event is a side-channel signal, not load-bearing
|
||||
// ignore — listeners are best-effort
|
||||
}
|
||||
// Notify other tabs/devices for this user so they refresh + drop the gate.
|
||||
try {
|
||||
@@ -1344,11 +1667,14 @@ export class AuthController extends PuterController {
|
||||
throw new HttpError(403, 'Account suspended.', {
|
||||
legacyCode: 'account_suspended',
|
||||
});
|
||||
if (!user.requires_card_verification) {
|
||||
// Phone normally comes first, but the fallback lets a phone-gated user
|
||||
// in once they've exhausted SMS attempts.
|
||||
const fallbackEligible = await this.isCardFallbackEligible(user);
|
||||
if (!user.requires_card_verification && !fallbackEligible) {
|
||||
res.json({ card_verified: true });
|
||||
return;
|
||||
}
|
||||
if (user.requires_phone_verification)
|
||||
if (user.requires_phone_verification && !fallbackEligible)
|
||||
throw new HttpError(
|
||||
409,
|
||||
'Phone verification must be completed first.',
|
||||
@@ -1396,6 +1722,16 @@ export class AuthController extends PuterController {
|
||||
// Kill switch: the extension reports the feature disabled — unstick
|
||||
// any user still carrying the flag instead of dead-ending them.
|
||||
if (setupEvent.enabled === false) {
|
||||
// A fallback user is here BECAUSE SMS isn't working for them, and
|
||||
// now the card path is off too — they stay phone-gated with no
|
||||
// way through. Surface it; don't clear a gate with nothing
|
||||
// verified.
|
||||
if (fallbackEligible)
|
||||
console.warn(
|
||||
'[card-verification/setup] card verification disabled;' +
|
||||
` fallback-eligible user ${user.uuid} remains` +
|
||||
' phone-gated with no working verification path',
|
||||
);
|
||||
await this.stores.user.update(user.id, {
|
||||
requires_card_verification: 0,
|
||||
});
|
||||
@@ -1452,11 +1788,13 @@ export class AuthController extends PuterController {
|
||||
throw new HttpError(404, 'User not found.', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
if (!user.requires_card_verification) {
|
||||
// Same fallback exception as setup: card may come before phone.
|
||||
const fallbackEligible = await this.isCardFallbackEligible(user);
|
||||
if (!user.requires_card_verification && !fallbackEligible) {
|
||||
res.json({ card_verified: true });
|
||||
return;
|
||||
}
|
||||
if (user.requires_phone_verification)
|
||||
if (user.requires_phone_verification && !fallbackEligible)
|
||||
throw new HttpError(
|
||||
409,
|
||||
'Phone verification must be completed first.',
|
||||
@@ -1473,6 +1811,7 @@ export class AuthController extends PuterController {
|
||||
fingerprint: null as string | null,
|
||||
funding: null as string | null,
|
||||
country: null as string | null,
|
||||
customer_id: null as string | null,
|
||||
};
|
||||
try {
|
||||
await this.clients.event?.emitAndWait(
|
||||
@@ -1486,6 +1825,12 @@ export class AuthController extends PuterController {
|
||||
|
||||
// Kill switch — same semantics as /card-verification/setup.
|
||||
if (confirmEvent.enabled === false) {
|
||||
if (fallbackEligible)
|
||||
console.warn(
|
||||
'[card-verification/confirm] card verification disabled;' +
|
||||
` fallback-eligible user ${user.uuid} remains` +
|
||||
' phone-gated with no working verification path',
|
||||
);
|
||||
await this.stores.user.update(user.id, {
|
||||
requires_card_verification: 0,
|
||||
});
|
||||
@@ -1503,8 +1848,15 @@ export class AuthController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
// A fallback card clears the phone gate too — the point of the
|
||||
// fallback. `fallbackEligible &&` makes the invariant local instead
|
||||
// of leaning on the 409 guard above: only a fallback user's card can
|
||||
// ever clear a phone gate.
|
||||
const clearedPhoneGate =
|
||||
fallbackEligible && Boolean(user.requires_phone_verification);
|
||||
await this.stores.user.update(user.id, {
|
||||
requires_card_verification: 0,
|
||||
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -1516,6 +1868,7 @@ export class AuthController extends PuterController {
|
||||
fingerprint: confirmEvent.fingerprint,
|
||||
funding: confirmEvent.funding,
|
||||
country: confirmEvent.country,
|
||||
customer_id: confirmEvent.customer_id,
|
||||
} as never,
|
||||
{},
|
||||
);
|
||||
@@ -1529,11 +1882,21 @@ export class AuthController extends PuterController {
|
||||
'user.card_verified',
|
||||
{ original_client_socket_id },
|
||||
);
|
||||
// The fallback cleared the phone gate too — tell phone-gate UIs.
|
||||
if (clearedPhoneGate)
|
||||
await this.services.socket?.send(
|
||||
{ room: user.id },
|
||||
'user.phone_verified',
|
||||
{ original_client_socket_id },
|
||||
);
|
||||
} catch {
|
||||
// ignore — best-effort
|
||||
}
|
||||
|
||||
res.json({ card_verified: true });
|
||||
res.json({
|
||||
card_verified: true,
|
||||
...(clearedPhoneGate ? { phone_verified: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// -- Password recovery -------------------------------------------
|
||||
@@ -2032,6 +2395,8 @@ export class AuthController extends PuterController {
|
||||
requires_email_confirmation: 0,
|
||||
});
|
||||
|
||||
await this.stores.oidc.unlinkAllByUserId(user.id);
|
||||
|
||||
try {
|
||||
this.clients.event?.emit(
|
||||
'user.email-changed' as never,
|
||||
@@ -2718,6 +3083,33 @@ export class AuthController extends PuterController {
|
||||
|
||||
await Promise.all([userPermGrantPromise, missingFSPathPromise]);
|
||||
|
||||
try {
|
||||
const a = app as {
|
||||
id?: number;
|
||||
uid?: string;
|
||||
index_url?: string | null;
|
||||
owner_user_id?: number | null;
|
||||
name?: string | null;
|
||||
};
|
||||
this.clients.event?.emit(
|
||||
'puter.app.authenticated' as never,
|
||||
{
|
||||
app_uid,
|
||||
app: {
|
||||
id: a.id,
|
||||
uid: a.uid,
|
||||
index_url: a.index_url ?? null,
|
||||
owner_user_id: a.owner_user_id ?? null,
|
||||
name: a.name ?? null,
|
||||
},
|
||||
user_id: req.actor!.user?.id ?? null,
|
||||
} as never,
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
// Fine if failed
|
||||
}
|
||||
|
||||
res.json({ token, app_uid });
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import {
|
||||
BasicTracerProvider,
|
||||
InMemorySpanExporter,
|
||||
SimpleSpanProcessor,
|
||||
} from '@opentelemetry/sdk-trace-base';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { actorUid, type Actor } from '../../core/actor.js';
|
||||
@@ -615,4 +621,65 @@ describe('DriverController.registerRoutes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DriverController.#handleCall tracing', () => {
|
||||
let routes: Captured;
|
||||
const exporter = new InMemorySpanExporter();
|
||||
const provider = new BasicTracerProvider({
|
||||
spanProcessors: [new SimpleSpanProcessor(exporter)],
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
routes = captureRoutes(controller);
|
||||
trace.setGlobalTracerProvider(provider);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await provider.shutdown();
|
||||
trace.disable();
|
||||
});
|
||||
|
||||
it('emits driver + downstream spans around a successful call', async () => {
|
||||
const actor = await makeUserActor();
|
||||
await server.stores.permission.setFlatUserPerm(
|
||||
actor.user!.id!,
|
||||
'service:puter-kvstore:ii:puter-kvstore',
|
||||
{
|
||||
permission: 'service:puter-kvstore:ii:puter-kvstore',
|
||||
deleted: false,
|
||||
issuer_user_id: actor.user!.id!,
|
||||
} as never,
|
||||
);
|
||||
exporter.reset();
|
||||
|
||||
const res = makeRes();
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-kvstore',
|
||||
method: 'set',
|
||||
args: { key: `k-${uuidv4()}`, value: 'v' },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await runWithContext({ actor }, () =>
|
||||
routes['POST /call'](req, res as unknown as Response, () => {}),
|
||||
);
|
||||
expect((res.body as { success: boolean }).success).toBe(true);
|
||||
|
||||
const names = exporter.getFinishedSpans().map((s) => s.name);
|
||||
const driverSpan = exporter
|
||||
.getFinishedSpans()
|
||||
.find((s) => s.name === 'driver.puter-kvstore.set');
|
||||
expect(driverSpan).toBeDefined();
|
||||
expect(driverSpan!.attributes).toMatchObject({
|
||||
driver: 'puter-kvstore',
|
||||
'driver.method': 'set',
|
||||
'driver.name': 'puter-kvstore',
|
||||
});
|
||||
// The call path exercises the permission gate and the KV store's
|
||||
// dynamo writes — both should have produced spans of their own.
|
||||
expect(names).toContain('permission.scan');
|
||||
expect(names.some((n) => n.startsWith('ddb.'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
void vi; // unused but reserved for future expansion
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import type { PermissionService } from '../../services/permission/PermissionService.js';
|
||||
import { PermissionUtil } from '../../services/permission/permissionUtil.js';
|
||||
import type { WithLifecycle } from '../../types';
|
||||
import { withSpan } from '../../util/span.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
type DriverInstance = WithLifecycle & Record<string, unknown>;
|
||||
@@ -382,11 +383,22 @@ export class DriverController extends PuterController {
|
||||
}
|
||||
|
||||
// Drivers read actor/context via the Context API — no drilled args.
|
||||
// The span ends when the method returns; for streamed results that
|
||||
// is stream start, not stream drain (same window the lifecycle
|
||||
// events below report as durationMs).
|
||||
const startedAt = Date.now();
|
||||
let result;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
result = await (fn as (...x: unknown[]) => any).call(driver, args);
|
||||
result = await withSpan(
|
||||
`driver.${ifaceName}.${method}`,
|
||||
{
|
||||
driver: ifaceName,
|
||||
'driver.method': method,
|
||||
'driver.name': resolved,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (fn as (...x: unknown[]) => any).call(driver, args),
|
||||
);
|
||||
} catch (e) {
|
||||
this.clients.event?.emit(
|
||||
`driver.${ifaceName}.${method}.error`,
|
||||
|
||||
@@ -1992,3 +1992,111 @@ describe('FSController metadata.objectKey injection', () => {
|
||||
expect(await streamToString(read.body)).toBe(attackerDecoy);
|
||||
});
|
||||
});
|
||||
|
||||
// ── associatedAppId entitlement gate ────────────────────────────────
|
||||
//
|
||||
// `associatedAppId` is client-supplied write metadata that's echoed back in
|
||||
// legacy FS responses. Binding a file to another tenant's private app would
|
||||
// turn `/stat` into an app-row enumeration oracle, so the write path drops
|
||||
// any association the actor isn't entitled to make.
|
||||
|
||||
describe('FSController associatedAppId entitlement gate', () => {
|
||||
// Seed an app row with a direct insert. `create` treats is_private as a
|
||||
// read-only column, and going through the store would prime the cache —
|
||||
// a raw insert leaves nothing cached so the gate's getById reads the DB.
|
||||
const makeApp = async (
|
||||
ownerUserId: number,
|
||||
opts: { is_private?: boolean } = {},
|
||||
): Promise<{ id: number }> => {
|
||||
const uid = `app-${uuidv4()}`;
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
uid,
|
||||
uid,
|
||||
'Gate App',
|
||||
'https://gate-app.puter.site/',
|
||||
ownerUserId,
|
||||
opts.is_private ? 1 : 0,
|
||||
],
|
||||
);
|
||||
const row = (
|
||||
await server.clients.db.read(
|
||||
'SELECT id FROM apps WHERE uid = ?',
|
||||
[uid],
|
||||
)
|
||||
)[0] as { id: number };
|
||||
return { id: row.id };
|
||||
};
|
||||
|
||||
// Write a file via the signed-write flow with the given associatedAppId
|
||||
// and return the committed entry's stored associatedAppId.
|
||||
const writeWithAssociation = async (
|
||||
actor: Actor,
|
||||
path: string,
|
||||
associatedAppId: number,
|
||||
): Promise<number | null> => {
|
||||
const startRes = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.startBatchWrites(
|
||||
makeReq<SignedWriteRequest[]>({
|
||||
body: [{ fileMetadata: { path, size: 3, associatedAppId } }],
|
||||
actor,
|
||||
}),
|
||||
startRes.res,
|
||||
),
|
||||
);
|
||||
const [started] = startRes.captured.body as SignedWriteResponse[];
|
||||
const completeRes = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: [{ uploadId: started.sessionId }],
|
||||
actor,
|
||||
}),
|
||||
completeRes.res,
|
||||
),
|
||||
);
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(path);
|
||||
return entry?.associatedAppId ?? null;
|
||||
};
|
||||
|
||||
it("drops an association to another tenant's private app", async () => {
|
||||
const victim = await makeUser();
|
||||
const attacker = await makeUser();
|
||||
const victimApp = await makeApp(victim.userId, { is_private: true });
|
||||
|
||||
const stored = await writeWithAssociation(
|
||||
attacker.actor,
|
||||
`/${attacker.actor.user!.username}/Documents/probe.txt`,
|
||||
victimApp.id,
|
||||
);
|
||||
expect(stored).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps an association to a public app the actor does not own', async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const publicApp = await makeApp(owner.userId, { is_private: false });
|
||||
|
||||
const stored = await writeWithAssociation(
|
||||
other.actor,
|
||||
`/${other.actor.user!.username}/Documents/public-assoc.txt`,
|
||||
publicApp.id,
|
||||
);
|
||||
expect(stored).toBe(publicApp.id);
|
||||
});
|
||||
|
||||
it('keeps an association to the actor’s own private app', async () => {
|
||||
const owner = await makeUser();
|
||||
const ownApp = await makeApp(owner.userId, { is_private: true });
|
||||
|
||||
const stored = await writeWithAssociation(
|
||||
owner.actor,
|
||||
`/${owner.actor.user!.username}/Documents/own-assoc.txt`,
|
||||
ownApp.id,
|
||||
);
|
||||
expect(stored).toBe(ownApp.id);
|
||||
});
|
||||
});
|
||||
@@ -119,6 +119,8 @@ export class FSController extends PuterController {
|
||||
requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata(
|
||||
requestBody.fileMetadata,
|
||||
requestBody,
|
||||
undefined,
|
||||
userId,
|
||||
);
|
||||
await this.#assertWriteAccess(req, requestBody.fileMetadata, {
|
||||
pathAlreadyNormalized: true,
|
||||
@@ -183,6 +185,7 @@ export class FSController extends PuterController {
|
||||
normalizedRequestBody.fileMetadata,
|
||||
normalizedRequestBody,
|
||||
appUidLookupCache,
|
||||
userId,
|
||||
);
|
||||
return normalizedRequestBody;
|
||||
}),
|
||||
@@ -364,6 +367,8 @@ export class FSController extends PuterController {
|
||||
requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata(
|
||||
requestBody.fileMetadata,
|
||||
requestBody,
|
||||
undefined,
|
||||
userId,
|
||||
);
|
||||
await this.#assertWriteAccess(req, requestBody.fileMetadata, {
|
||||
pathAlreadyNormalized: true,
|
||||
@@ -497,6 +502,7 @@ export class FSController extends PuterController {
|
||||
item.fileMetadata,
|
||||
item,
|
||||
appUidLookupCache,
|
||||
userId,
|
||||
),
|
||||
})),
|
||||
),
|
||||
@@ -718,6 +724,7 @@ export class FSController extends PuterController {
|
||||
normalizedRequestBody.fileMetadata,
|
||||
normalizedRequestBody,
|
||||
appUidLookupCache,
|
||||
userId,
|
||||
);
|
||||
return normalizedRequestBody;
|
||||
}),
|
||||
@@ -1695,6 +1702,7 @@ export class FSController extends PuterController {
|
||||
fileMetadata: FSEntryWriteInput,
|
||||
fallbackSource?: unknown,
|
||||
appUidLookupCache?: Map<string, Promise<number | null>>,
|
||||
actorUserId?: number,
|
||||
): Promise<FSEntryWriteInput> {
|
||||
const metadataRecord = this.#toObjectRecord(fileMetadata);
|
||||
const fallbackRecord = this.#toObjectRecord(fallbackSource);
|
||||
@@ -1708,10 +1716,11 @@ export class FSController extends PuterController {
|
||||
),
|
||||
);
|
||||
if (associatedAppId !== undefined) {
|
||||
return {
|
||||
...fileMetadata,
|
||||
return this.#withAssociatedAppId(
|
||||
fileMetadata,
|
||||
associatedAppId,
|
||||
};
|
||||
actorUserId,
|
||||
);
|
||||
}
|
||||
|
||||
const appUid = this.#firstDefined(
|
||||
@@ -1745,9 +1754,46 @@ export class FSController extends PuterController {
|
||||
if (resolvedAppId === null) {
|
||||
return fileMetadata;
|
||||
}
|
||||
return this.#withAssociatedAppId(
|
||||
fileMetadata,
|
||||
resolvedAppId,
|
||||
actorUserId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind `associatedAppId` onto the write input only when the actor is
|
||||
* entitled to reference that app. `associatedAppId` is client-supplied
|
||||
* and never trusted for authz, but it's echoed back in legacy FS
|
||||
* responses — so an attacker could plant another tenant's private app id
|
||||
* to confirm the row exists (an enumeration oracle) and harvest its
|
||||
* metadata. Allow binding to public apps (their existence isn't secret)
|
||||
* or to apps the actor owns; drop the association otherwise so the file
|
||||
* simply carries no associated app.
|
||||
*
|
||||
* `#resolveWriteFileMetadata` has already copied the raw client value onto
|
||||
* `fileMetadata`, so a dropped association must be actively stripped — not
|
||||
* merely left unset.
|
||||
*/
|
||||
async #withAssociatedAppId(
|
||||
fileMetadata: FSEntryWriteInput,
|
||||
appId: number,
|
||||
actorUserId?: number,
|
||||
): Promise<FSEntryWriteInput> {
|
||||
const app = await this.stores.app.getById(appId);
|
||||
const isPrivate =
|
||||
!!app && (Boolean(app.is_private) || Boolean(app.protected));
|
||||
const isOwner =
|
||||
actorUserId !== undefined &&
|
||||
this.#toNumber(app?.owner_user_id) === actorUserId;
|
||||
if (!app || (isPrivate && !isOwner)) {
|
||||
const { associatedAppId: _dropped, ...rest } =
|
||||
fileMetadata as unknown as Record<string, unknown>;
|
||||
return rest as unknown as FSEntryWriteInput;
|
||||
}
|
||||
return {
|
||||
...fileMetadata,
|
||||
associatedAppId: resolvedAppId,
|
||||
associatedAppId: appId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -765,6 +765,121 @@ describe('LegacyFSController.copy', () => {
|
||||
`/${username}/Pictures/renamed-copy`,
|
||||
);
|
||||
});
|
||||
|
||||
it('copies an empty file (no backing S3 object) without erroring', async () => {
|
||||
// Empty files created via /touch have size 0 and no S3 object —
|
||||
// bucket is null. Copy must clone them as empty-file entries rather
|
||||
// than issuing a CopyObject, which would throw S3 NoSuchKey.
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const src = `/${username}/Documents/empty.txt`;
|
||||
await withActor(actor, () =>
|
||||
controller.touch(
|
||||
makeReq({ body: { path: src }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.copy(
|
||||
makeReq({
|
||||
body: { source: src, destination: `/${username}/Pictures` },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
|
||||
const body = captured.body as Array<{ copied: { path: string } }>;
|
||||
expect(body[0].copied.path).toBe(`/${username}/Pictures/empty.txt`);
|
||||
const copied = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Pictures/empty.txt`,
|
||||
);
|
||||
expect(copied).not.toBeNull();
|
||||
expect(copied!.size).toBe(0);
|
||||
// Original is untouched.
|
||||
expect(await server.stores.fsEntry.getEntryByPath(src)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('copying a ghost file (S3 object missing) 404s and cleans up the orphan', async () => {
|
||||
// A real file whose backing S3 object has vanished keeps a non-null
|
||||
// bucket, so it isn't an empty file. CopyObject would throw S3
|
||||
// NoSuchKey; copy must surface a clean 404 and remove the orphan row
|
||||
// rather than bubbling a 500. Mirrors readContent's ghost handling.
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const src = `/${username}/Documents/ghost.txt`;
|
||||
const content = Buffer.from('i will be deleted from s3');
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path: src,
|
||||
size: content.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: content,
|
||||
});
|
||||
|
||||
// Delete the backing S3 object directly, leaving the DB row behind.
|
||||
const entry = (await server.stores.fsEntry.getEntryByPath(src))!;
|
||||
await server.stores.s3Object.deleteObject(
|
||||
server.stores.s3Object.resolveBucket(entry.bucket),
|
||||
entry.uuid,
|
||||
server.stores.s3Object.resolveRegion(entry.bucketRegion),
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.copy(
|
||||
makeReq({
|
||||
body: {
|
||||
source: src,
|
||||
destination: `/${username}/Pictures`,
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
|
||||
// The ghost handler removed the orphaned source row.
|
||||
expect(await server.stores.fsEntry.getEntryByPath(src)).toBeNull();
|
||||
// No partial copy was left behind.
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Pictures/ghost.txt`,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('reads an empty file as empty content without deleting it', async () => {
|
||||
// Reading an empty file (no S3 object) must not throw NoSuchKey nor
|
||||
// trip the ghost-file cleanup, which would delete the entry.
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const src = `/${username}/Documents/readme-empty.txt`;
|
||||
await withActor(actor, () =>
|
||||
controller.touch(
|
||||
makeReq({ body: { path: src }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const entry = (await server.stores.fsEntry.getEntryByPath(src))!;
|
||||
|
||||
const download = await server.services.fs.readContent(entry);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of download.body) {
|
||||
chunks.push(Buffer.from(chunk as Uint8Array));
|
||||
}
|
||||
expect(Buffer.concat(chunks).byteLength).toBe(0);
|
||||
expect(download.contentLength).toBe(0);
|
||||
// The entry must still exist — the ghost handler must NOT have run.
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(src),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── move ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -158,8 +158,7 @@ export class LegacyFSController extends PuterController {
|
||||
|
||||
router.get('/get-launch-apps', apiOptions, async (req, res) => {
|
||||
const recommendedSvc = this.services.recommendedApps as unknown as
|
||||
| { getRecommendedApps?: () => Promise<unknown[]> }
|
||||
| undefined;
|
||||
{ getRecommendedApps?: () => Promise<unknown[]> } | undefined;
|
||||
const recommended = recommendedSvc?.getRecommendedApps
|
||||
? await recommendedSvc.getRecommendedApps()
|
||||
: [];
|
||||
@@ -194,6 +193,11 @@ export class LegacyFSController extends PuterController {
|
||||
godmode: Boolean(app.godmode),
|
||||
maximize_on_start: Boolean(app.maximize_on_start),
|
||||
index_url: app.index_url,
|
||||
// An app with no owner isn't owned by a Puter user —
|
||||
// it's an "external" (origin-bootstrapped) app.
|
||||
external:
|
||||
app.owner_user_id == null ||
|
||||
app.owner_user_id === '',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -555,9 +559,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<string, unknown>
|
||||
| null
|
||||
| undefined,
|
||||
Record<string, unknown> | null | undefined,
|
||||
});
|
||||
const oldPath = source.path;
|
||||
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
|
||||
@@ -973,8 +975,7 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
type SignedOrEmpty =
|
||||
| (SignedFile & { path?: string })
|
||||
| Record<string, never>;
|
||||
(SignedFile & { path?: string }) | Record<string, never>;
|
||||
const result: { signatures: SignedOrEmpty[]; token?: string } = {
|
||||
signatures: [],
|
||||
};
|
||||
@@ -1515,10 +1516,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',
|
||||
@@ -2196,120 +2194,6 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
// -- Helpers ---------------------------------------------------------
|
||||
|
||||
#parsePositiveIntegerQuery(
|
||||
query: Record<string, unknown>,
|
||||
key: string,
|
||||
message: string,
|
||||
): number | undefined {
|
||||
const value = query[key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new HttpError(400, message, { legacyCode: 'bad_request' });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
#parseNonNegativeIntegerQuery(
|
||||
query: Record<string, unknown>,
|
||||
key: string,
|
||||
message: string,
|
||||
): number | undefined {
|
||||
const value = query[key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new HttpError(400, message, { legacyCode: 'bad_request' });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
#normalizeRangeHeader(rangeHeader: string): string | undefined {
|
||||
const firstRange = rangeHeader.includes(',')
|
||||
? rangeHeader.split(',')[0]?.trim()
|
||||
: rangeHeader.trim();
|
||||
if (!firstRange) return undefined;
|
||||
|
||||
const matches = firstRange.match(/^bytes=(\d+)-(\d*)$/);
|
||||
if (!matches) return undefined;
|
||||
|
||||
const [, start, end] = matches;
|
||||
return end ? `bytes=${start}-${end}` : `bytes=${start}-`;
|
||||
}
|
||||
|
||||
#pipeLimitedLines(
|
||||
source: NodeJS.ReadableStream,
|
||||
res: Response,
|
||||
lineCount: number,
|
||||
): void {
|
||||
let remainingLines = lineCount;
|
||||
let isClosed = false;
|
||||
|
||||
const closeSource = () => {
|
||||
if (isClosed) return;
|
||||
isClosed = true;
|
||||
if ('destroy' in source && typeof source.destroy === 'function') {
|
||||
source.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
source.on('error', (err) => {
|
||||
if (!isClosed) {
|
||||
isClosed = true;
|
||||
res.destroy(err);
|
||||
}
|
||||
});
|
||||
|
||||
res.on('close', () => {
|
||||
closeSource();
|
||||
});
|
||||
|
||||
source.on('data', (chunk: Buffer | string) => {
|
||||
if (isClosed) return;
|
||||
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
let endIndex = buffer.length;
|
||||
|
||||
for (let index = 0; index < buffer.length; index++) {
|
||||
if (buffer[index] !== 0x0a) continue;
|
||||
remainingLines -= 1;
|
||||
if (remainingLines === 0) {
|
||||
endIndex = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex > 0) {
|
||||
const canContinue = res.write(buffer.subarray(0, endIndex));
|
||||
if (!canContinue) {
|
||||
source.pause();
|
||||
res.once('drain', () => {
|
||||
if (!isClosed) {
|
||||
source.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex !== buffer.length) {
|
||||
res.end();
|
||||
closeSource();
|
||||
}
|
||||
});
|
||||
|
||||
source.on('end', () => {
|
||||
if (!isClosed) {
|
||||
isClosed = true;
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#requireActor(req: Request) {
|
||||
const actor = req.actor;
|
||||
if (!actor) {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { loadLegacyAssociatedApps } from './legacyFsHelpers.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
|
||||
const entryWithApp = (associatedAppId: number): FSEntry =>
|
||||
({ associatedAppId }) as unknown as FSEntry;
|
||||
|
||||
const appRow = (overrides: Record<string, unknown>): Record<string, unknown> => ({
|
||||
id: 1,
|
||||
uid: 'app-1',
|
||||
owner_user_id: 99,
|
||||
app_owner: 99,
|
||||
icon: 'icon',
|
||||
name: 'an-app',
|
||||
title: 'An App',
|
||||
description: 'desc',
|
||||
godmode: 1,
|
||||
maximize_on_start: 1,
|
||||
index_url: 'https://an-app.puter.site/',
|
||||
background: 1,
|
||||
metadata: { secret: true },
|
||||
is_private: 0,
|
||||
protected: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const fakeStore = (row: Record<string, unknown>) => ({
|
||||
getByIds: async (ids: number[]) =>
|
||||
new Map(ids.map((id) => [id, { ...row, id }])),
|
||||
});
|
||||
|
||||
describe('loadLegacyAssociatedApps associated_app redaction', () => {
|
||||
it('never leaks owner identifiers, even for public apps', async () => {
|
||||
const out = await loadLegacyAssociatedApps(
|
||||
fakeStore(appRow({ is_private: 0, protected: 0 })),
|
||||
[entryWithApp(1)],
|
||||
);
|
||||
const app = out.get(1)!;
|
||||
expect(app).not.toHaveProperty('owner_user_id');
|
||||
expect(app).not.toHaveProperty('app_owner');
|
||||
});
|
||||
|
||||
it('passes through hosting + capability fields for public apps', async () => {
|
||||
const out = await loadLegacyAssociatedApps(
|
||||
fakeStore(appRow({ is_private: 0, protected: 0, godmode: 1 })),
|
||||
[entryWithApp(1)],
|
||||
);
|
||||
const app = out.get(1)!;
|
||||
expect(app.index_url).toBe('https://an-app.puter.site/');
|
||||
expect(app.godmode).toBe(1);
|
||||
expect(app.maximize_on_start).toBe(1);
|
||||
});
|
||||
|
||||
it('redacts hosting + capability fields for private apps', async () => {
|
||||
const out = await loadLegacyAssociatedApps(
|
||||
fakeStore(appRow({ is_private: 1, godmode: 1 })),
|
||||
[entryWithApp(1)],
|
||||
);
|
||||
const app = out.get(1)!;
|
||||
// Existence + display fields still surface...
|
||||
expect(app.uid).toBe('app-1');
|
||||
expect(app.name).toBe('an-app');
|
||||
expect(app.is_private).toBe(1);
|
||||
// ...but the sensitive bits are stripped.
|
||||
expect(app.index_url).toBeNull();
|
||||
expect(app.godmode).toBe(0);
|
||||
expect(app.maximize_on_start).toBe(0);
|
||||
expect(app.background).toBe(0);
|
||||
expect(app.metadata).toBeNull();
|
||||
expect(app).not.toHaveProperty('owner_user_id');
|
||||
});
|
||||
|
||||
it('redacts the same fields for protected apps', async () => {
|
||||
const out = await loadLegacyAssociatedApps(
|
||||
fakeStore(appRow({ is_private: 0, protected: 1, godmode: 1 })),
|
||||
[entryWithApp(1)],
|
||||
);
|
||||
const app = out.get(1)!;
|
||||
expect(app.protected).toBe(1);
|
||||
expect(app.index_url).toBeNull();
|
||||
expect(app.godmode).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -274,21 +274,33 @@ const toIntBool = (v: unknown): number => (v ? 1 : 0);
|
||||
* embedded in legacy FS entries. Booleans round-trip back to integers (0/1)
|
||||
* because the v1 wire contract emits them that way and existing clients key
|
||||
* off it. Other columns pass through as-is — `metadata` is already parsed.
|
||||
*
|
||||
* Two redactions, because `associatedAppId` can name an app the actor does
|
||||
* not own (the column is client-writable display metadata, never trusted for
|
||||
* authz — see FSController) and may point cross-tenant:
|
||||
* - Owner identifiers (`owner_user_id`, `app_owner`) are never emitted —
|
||||
* they're internal user references with no client-side display use.
|
||||
* - For private or protected apps, the direct hosting URL and launch
|
||||
* capability flags (`index_url`, `godmode`, `maximize_on_start`,
|
||||
* `background`, `metadata`) are dropped. This mirrors AppDriver, which
|
||||
* withholds `index_url` from callers without read entitlement. Display
|
||||
* fields (name, icon, title) still pass through so the GUI can label the
|
||||
* file's associated app.
|
||||
*/
|
||||
function mapAppForLegacyAssociatedApp(
|
||||
app: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const gated = Boolean(app.is_private) || Boolean(app.protected);
|
||||
return {
|
||||
id: app.id,
|
||||
uid: app.uid,
|
||||
owner_user_id: app.owner_user_id,
|
||||
icon: app.icon,
|
||||
name: app.name,
|
||||
title: app.title,
|
||||
description: app.description,
|
||||
godmode: toIntBool(app.godmode),
|
||||
maximize_on_start: toIntBool(app.maximize_on_start),
|
||||
index_url: app.index_url,
|
||||
godmode: gated ? 0 : toIntBool(app.godmode),
|
||||
maximize_on_start: gated ? 0 : toIntBool(app.maximize_on_start),
|
||||
index_url: gated ? null : app.index_url,
|
||||
approved_for_listing: toIntBool(app.approved_for_listing),
|
||||
approved_for_opening_items: toIntBool(app.approved_for_opening_items),
|
||||
approved_for_incentive_program: toIntBool(
|
||||
@@ -297,9 +309,8 @@ function mapAppForLegacyAssociatedApp(
|
||||
timestamp: app.timestamp ?? null,
|
||||
last_review: app.last_review ?? null,
|
||||
tags: app.tags ?? null,
|
||||
app_owner: app.app_owner ?? null,
|
||||
background: toIntBool(app.background),
|
||||
metadata: app.metadata ?? null,
|
||||
background: gated ? 0 : toIntBool(app.background),
|
||||
metadata: gated ? null : (app.metadata ?? null),
|
||||
protected: toIntBool(app.protected),
|
||||
is_private: toIntBool(app.is_private),
|
||||
};
|
||||
|
||||
@@ -165,6 +165,43 @@ describe('HomepageController shell routes', () => {
|
||||
expect(html).toContain('Puter');
|
||||
});
|
||||
|
||||
it('exposes disable_temp_users to the GUI when signups are disabled', async () => {
|
||||
const homepageConfig = server.controllers.homepage.config as {
|
||||
disable_user_signup?: boolean;
|
||||
};
|
||||
const prev = homepageConfig.disable_user_signup;
|
||||
homepageConfig.disable_user_signup = true;
|
||||
const { res, captured } = makeRes();
|
||||
try {
|
||||
await callRoute('get', '/', makeReq({ path: '/' }), res);
|
||||
} finally {
|
||||
homepageConfig.disable_user_signup = prev;
|
||||
}
|
||||
expect(String(captured.body)).toContain('"disable_temp_users":true');
|
||||
});
|
||||
|
||||
it('keeps an operator-set gui_params.disable_temp_users when the flag is off', async () => {
|
||||
const homepageConfig = server.controllers.homepage.config as {
|
||||
disable_user_signup?: boolean;
|
||||
gui_params?: Record<string, unknown>;
|
||||
};
|
||||
const prevFlag = homepageConfig.disable_user_signup;
|
||||
const prevGuiParams = homepageConfig.gui_params;
|
||||
homepageConfig.disable_user_signup = false;
|
||||
homepageConfig.gui_params = {
|
||||
...prevGuiParams,
|
||||
disable_temp_users: true,
|
||||
};
|
||||
const { res, captured } = makeRes();
|
||||
try {
|
||||
await callRoute('get', '/', makeReq({ path: '/' }), res);
|
||||
} finally {
|
||||
homepageConfig.disable_user_signup = prevFlag;
|
||||
homepageConfig.gui_params = prevGuiParams;
|
||||
}
|
||||
expect(String(captured.body)).toContain('"disable_temp_users":true');
|
||||
});
|
||||
|
||||
it('still serves the shell when an authenticated actor is present', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
@@ -173,12 +210,14 @@ describe('HomepageController shell routes', () => {
|
||||
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
|
||||
});
|
||||
|
||||
it('serves the shell on /settings, /dashboard, /action, /@:username', async () => {
|
||||
it('serves the shell on /settings, /dashboard, /desktop, /action, /@:username', async () => {
|
||||
for (const path of [
|
||||
'/settings',
|
||||
'/settings/*splat',
|
||||
'/dashboard',
|
||||
'/dashboard/',
|
||||
'/desktop',
|
||||
'/desktop/',
|
||||
'/action/*splat',
|
||||
'/@:username',
|
||||
]) {
|
||||
|
||||
@@ -82,6 +82,9 @@ export class HomepageController extends PuterController {
|
||||
router.get('/dashboard', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/dashboard/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/desktop', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/desktop/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/action/*splat', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/@:username', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
@@ -52,6 +52,12 @@ const TEST_ORIGIN = 'http://test.local';
|
||||
let server: PuterServer;
|
||||
let router: PuterRouter;
|
||||
|
||||
// Stand-in for the abuse extension's signup veto. EventClient has no off(),
|
||||
// so one shared listener is installed in beforeAll and tests swap the
|
||||
// override in and out (same pattern as AuthController.test.ts).
|
||||
type SignupValidateOverride = (data: Record<string, unknown>) => void;
|
||||
let signupValidateOverride: SignupValidateOverride | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
origin: TEST_ORIGIN,
|
||||
@@ -75,6 +81,14 @@ beforeAll(async () => {
|
||||
} as never);
|
||||
router = new PuterRouter();
|
||||
server.controllers.oidc.registerRoutes(router);
|
||||
server.clients.event.on(
|
||||
'puter.signup.validate',
|
||||
(_k: unknown, data: unknown) => {
|
||||
if (signupValidateOverride) {
|
||||
signupValidateOverride(data as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -83,6 +97,7 @@ afterAll(async () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
signupValidateOverride = null;
|
||||
});
|
||||
|
||||
interface CapturedResponse {
|
||||
@@ -92,6 +107,7 @@ interface CapturedResponse {
|
||||
redirectUrl?: string;
|
||||
headers: Record<string, string>;
|
||||
cookies: Array<{ name: string; value: string; opts?: unknown }>;
|
||||
clearedCookies: Array<{ name: string; opts?: unknown }>;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
@@ -100,6 +116,7 @@ const makeReq = (init: {
|
||||
query?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
cookies?: Record<string, string>;
|
||||
method?: string;
|
||||
}): Request => {
|
||||
return {
|
||||
@@ -107,6 +124,7 @@ const makeReq = (init: {
|
||||
query: init.query ?? {},
|
||||
params: init.params ?? {},
|
||||
headers: init.headers ?? {},
|
||||
cookies: init.cookies ?? {},
|
||||
method: init.method ?? 'GET',
|
||||
} as unknown as Request;
|
||||
};
|
||||
@@ -117,6 +135,7 @@ const makeRes = () => {
|
||||
body: undefined,
|
||||
headers: {},
|
||||
cookies: [],
|
||||
clearedCookies: [],
|
||||
};
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
@@ -155,6 +174,10 @@ const makeRes = () => {
|
||||
captured.cookies.push({ name, value, opts });
|
||||
return res;
|
||||
}),
|
||||
clearCookie: vi.fn((name: string, opts?: unknown) => {
|
||||
captured.clearedCookies.push({ name, opts });
|
||||
return res;
|
||||
}),
|
||||
type: vi.fn(() => res),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
@@ -593,6 +616,7 @@ describe('OIDCController login callback', () => {
|
||||
);
|
||||
expect(captured.redirectStatus).toBe(302);
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.redirectUrl).toContain('message=account_suspended');
|
||||
// No session cookie issued for suspended accounts.
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
});
|
||||
@@ -674,9 +698,7 @@ describe('OIDCController login callback', () => {
|
||||
expect(captured.redirectUrl).toContain('embedded_in_popup=true');
|
||||
expect(captured.redirectUrl).toContain('msg_id=msg-99');
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
// 'This account is suspended.' is not in ALLOWED_ERRORS, so the
|
||||
// builder falls back to 'unauthorized'.
|
||||
expect(captured.redirectUrl).toContain('message=unauthorized');
|
||||
expect(captured.redirectUrl).toContain('message=account_suspended');
|
||||
expect(captured.redirectUrl).toContain(
|
||||
`opener_origin=${encodeURIComponent('http://opener.test')}`,
|
||||
);
|
||||
@@ -767,6 +789,291 @@ describe('OIDCController login callback', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- Browser binding / login-CSRF --------------------------------------
|
||||
|
||||
describe('OIDCController signup veto (abuse harness)', () => {
|
||||
const vetoWithTrail = (email: string, trailId: string) => {
|
||||
signupValidateOverride = (data) => {
|
||||
if (data.email !== email) return;
|
||||
data.allow = false;
|
||||
data.trail_id = trailId;
|
||||
};
|
||||
};
|
||||
|
||||
const stubIdp = (sub: string, email: string) => {
|
||||
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
|
||||
access_token: 'access',
|
||||
id_token: 'id',
|
||||
} as never);
|
||||
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
} as never);
|
||||
};
|
||||
|
||||
it('createUserFromOIDC returns signup_blocked and the trail id as requestCode', async () => {
|
||||
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
vetoWithTrail(email, 'trail-svc-1');
|
||||
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(false);
|
||||
expect(created.code).toBe('signup_blocked');
|
||||
expect(created.requestCode).toBe('trail-svc-1');
|
||||
});
|
||||
|
||||
it('login callback redirects with signup_blocked + request_code when first sign-in is vetoed', async () => {
|
||||
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
vetoWithTrail(email, 'trail-login-1');
|
||||
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
|
||||
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectStatus).toBe(302);
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.redirectUrl).toContain('action=login');
|
||||
expect(captured.redirectUrl).toContain('message=signup_blocked');
|
||||
expect(captured.redirectUrl).toContain('request_code=trail-login-1');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('signup callback redirects with signup_blocked + request_code when vetoed', async () => {
|
||||
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
vetoWithTrail(email, 'trail-signup-1');
|
||||
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
|
||||
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/signup',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectStatus).toBe(302);
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.redirectUrl).toContain('action=signup');
|
||||
expect(captured.redirectUrl).toContain('message=signup_blocked');
|
||||
expect(captured.redirectUrl).toContain('request_code=trail-signup-1');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a veto with no trail id still redirects with signup_blocked and no request_code', async () => {
|
||||
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
signupValidateOverride = (data) => {
|
||||
if (data.email !== email) return;
|
||||
data.allow = false;
|
||||
};
|
||||
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
|
||||
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/signup',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectUrl).toContain('message=signup_blocked');
|
||||
expect(captured.redirectUrl).not.toContain('request_code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDCController browser binding', () => {
|
||||
const NONCE_COOKIE = 'puter_oidc_nonce';
|
||||
|
||||
const stubIdP = (sub: string, email: string) => {
|
||||
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
|
||||
access_token: 'access',
|
||||
id_token: 'id',
|
||||
} as never);
|
||||
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
} as never);
|
||||
};
|
||||
|
||||
it('/start sets an HttpOnly nonce cookie matching the nonce embedded in state', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/:provider/start',
|
||||
makeReq({ params: { provider: 'custom' } }),
|
||||
res,
|
||||
);
|
||||
|
||||
const nonceCookie = captured.cookies.find(
|
||||
(c) => c.name === NONCE_COOKIE,
|
||||
);
|
||||
expect(nonceCookie).toBeTruthy();
|
||||
expect(nonceCookie?.value).toBeTruthy();
|
||||
expect((nonceCookie?.opts as { httpOnly?: boolean })?.httpOnly).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// The cookie value must equal the nonce baked into the signed state.
|
||||
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
|
||||
'state',
|
||||
);
|
||||
const decoded = oidc().verifyState(state!);
|
||||
expect(decoded?.nonce).toBe(nonceCookie?.value);
|
||||
});
|
||||
|
||||
it('completes login when the nonce cookie matches the state nonce', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `bind-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const nonce = 'browser-nonce-match';
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
nonce,
|
||||
});
|
||||
stubIdP(sub, email);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({
|
||||
query: { code: 'c', state },
|
||||
cookies: { [NONCE_COOKIE]: nonce },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
// Session cookie issued; single-use nonce cookie cleared.
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
|
||||
expect(
|
||||
captured.clearedCookies.some((c) => c.name === NONCE_COOKIE),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects login (no session cookie) when the nonce cookie is absent — the login-CSRF case', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
nonce: 'attacker-flow-nonce',
|
||||
});
|
||||
// If enforcement were missing, this would resolve a user and set a
|
||||
// session cookie for the victim's browser. It must not get that far.
|
||||
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
// Victim's browser has no nonce cookie for the attacker's flow.
|
||||
makeReq({ query: { code: 'c', state }, cookies: {} }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectStatus).toBe(302);
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
// We bail before ever exchanging the code.
|
||||
expect(exchangeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects login when the nonce cookie does not match the state nonce', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
nonce: 'expected-nonce',
|
||||
});
|
||||
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({
|
||||
query: { code: 'c', state },
|
||||
cookies: { [NONCE_COOKIE]: 'a-different-nonce' },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
expect(exchangeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects the revalidate callback (400) when the nonce cookie is missing', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
flow: 'revalidate',
|
||||
user_uuid: uuidv4(),
|
||||
nonce: 'reval-nonce',
|
||||
});
|
||||
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/revalidate',
|
||||
makeReq({ query: { code: 'c', state }, cookies: {} }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.statusCode).toBe(400);
|
||||
expect(exchangeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets legacy nonce-less state through (deploy grace) without touching the nonce cookie', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `legacy-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
// No `nonce` field — mimics a state signed before this shipped.
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
});
|
||||
stubIdP(sub, email);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({ query: { code: 'c', state }, cookies: {} }),
|
||||
res,
|
||||
);
|
||||
|
||||
// Proceeds as before; no nonce cookie is cleared for legacy states.
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
|
||||
expect(
|
||||
captured.clearedCookies.some((c) => c.name === NONCE_COOKIE),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /auth/oidc/callback/signup ──────────────────────────────────────
|
||||
|
||||
describe('OIDCController signup callback', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
@@ -26,45 +27,63 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
|
||||
const REVALIDATION_EXPIRY_SEC = 300;
|
||||
|
||||
// Companion cookie that binds an OIDC flow to the browser that started it.
|
||||
// Expiry mirrors STATE_EXPIRY_SEC in OIDCService — the state and its
|
||||
// browser-binding cookie must expire together.
|
||||
const OIDC_NONCE_COOKIE_NAME = 'puter_oidc_nonce';
|
||||
const OIDC_NONCE_EXPIRY_SEC = 600;
|
||||
|
||||
const OIDC_ERROR_REDIRECT_MAP: Record<string, Record<string, string>> = {
|
||||
login: { account_not_found: 'signup', other: 'login' },
|
||||
signup: { account_already_exists: 'login', other: 'signup' },
|
||||
};
|
||||
|
||||
const ALLOWED_ERRORS = ['account_suspended', 'unauthorized'] as const;
|
||||
// The `message` query param is clamped to these codes — the GUI maps them to
|
||||
// display text. Free-text errors (which may describe internals or, for vetoed
|
||||
// signups, the block reason) never reach the redirect URL.
|
||||
const ALLOWED_ERRORS = [
|
||||
'account_suspended',
|
||||
'unauthorized',
|
||||
'signup_blocked',
|
||||
] as const;
|
||||
|
||||
function buildErrorRedirectUrl(
|
||||
origin: string,
|
||||
sourceFlow: string,
|
||||
errorCondition: string,
|
||||
message: (typeof ALLOWED_ERRORS)[number],
|
||||
message: string,
|
||||
stateDecoded?: Record<string, unknown>,
|
||||
requestCode?: string,
|
||||
): string {
|
||||
const targetFlow =
|
||||
OIDC_ERROR_REDIRECT_MAP[sourceFlow]?.[errorCondition] ?? sourceFlow;
|
||||
const base = origin.replace(/\/$/, '') || '/';
|
||||
const clamped = (ALLOWED_ERRORS as readonly string[]).includes(message)
|
||||
? message
|
||||
: 'unauthorized';
|
||||
|
||||
let params: URLSearchParams;
|
||||
if (stateDecoded?.embedded_in_popup && stateDecoded?.msg_id != null) {
|
||||
const params = new URLSearchParams({
|
||||
params = new URLSearchParams({
|
||||
embedded_in_popup: 'true',
|
||||
msg_id: String(stateDecoded.msg_id),
|
||||
auth_error: '1',
|
||||
message: ALLOWED_ERRORS.includes(message)
|
||||
? message
|
||||
: 'unauthorized',
|
||||
message: clamped,
|
||||
action: targetFlow,
|
||||
});
|
||||
if (stateDecoded?.opener_origin) {
|
||||
params.set('opener_origin', String(stateDecoded.opener_origin));
|
||||
}
|
||||
return `${base}/?${params.toString()}`;
|
||||
} else {
|
||||
params = new URLSearchParams({
|
||||
action: targetFlow,
|
||||
auth_error: '1',
|
||||
message: clamped,
|
||||
});
|
||||
}
|
||||
if (requestCode) {
|
||||
params.set('request_code', requestCode);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
action: targetFlow,
|
||||
auth_error: '1',
|
||||
message: ALLOWED_ERRORS.includes(message) ? message : 'unauthorized',
|
||||
});
|
||||
return `${base}/?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -73,6 +92,14 @@ function appendQueryParam(url: string, key: string, value: string): string {
|
||||
return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
}
|
||||
|
||||
/** Length-safe constant-time string compare (never throws on mismatch). */
|
||||
function constantTimeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ba.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `target` parses as a URL whose origin equals `origin`. Used to
|
||||
* clamp OIDC redirect targets — `startsWith` would accept
|
||||
@@ -139,6 +166,19 @@ export class OIDCController extends PuterController {
|
||||
|
||||
let appRedirectUri = flowRedirects[flow] ?? (origin || '/');
|
||||
|
||||
// Optional GUI return path so login started from /desktop or
|
||||
// /dashboard lands back there. Strict whitelist — never a
|
||||
// client-supplied URL (no open redirect).
|
||||
const rawReturnTo = Array.isArray(req.query.return_to)
|
||||
? req.query.return_to[0]
|
||||
: req.query.return_to;
|
||||
if (
|
||||
(flow === 'login' || flow === 'signup') &&
|
||||
(rawReturnTo === '/desktop' || rawReturnTo === '/dashboard')
|
||||
) {
|
||||
appRedirectUri = `${origin}${rawReturnTo}`;
|
||||
}
|
||||
|
||||
// Popup support
|
||||
const rawPopup = Array.isArray(req.query.embedded_in_popup)
|
||||
? req.query.embedded_in_popup[0]
|
||||
@@ -198,6 +238,17 @@ export class OIDCController extends PuterController {
|
||||
statePayload.flow = 'revalidate';
|
||||
}
|
||||
|
||||
// Bind this flow to the initiating browser: a single-use
|
||||
// nonce lives both in the signed `state` and in an HttpOnly
|
||||
// companion cookie. The callback requires them to match, so a
|
||||
// `state` captured from an attacker's own flow can't be
|
||||
// replayed in a victim's browser (login-CSRF / session
|
||||
// fixation).
|
||||
const browserNonce = crypto
|
||||
.randomBytes(32)
|
||||
.toString('base64url');
|
||||
statePayload.nonce = browserNonce;
|
||||
|
||||
const state = this.services.oidc.signState(statePayload);
|
||||
const url = await this.services.oidc.getAuthorizationUrl(
|
||||
provider,
|
||||
@@ -211,6 +262,15 @@ export class OIDCController extends PuterController {
|
||||
{ legacyCode: 'internal_error' },
|
||||
);
|
||||
|
||||
res.cookie(OIDC_NONCE_COOKIE_NAME, browserNonce, {
|
||||
// Same flags as the session cookie: SameSite=None;Secure
|
||||
// on HTTPS so the cookie survives Apple's cross-site
|
||||
// form_post callback; Lax on plain-HTTP self-host.
|
||||
...sessionCookieFlags(this.config),
|
||||
httpOnly: true,
|
||||
maxAge: OIDC_NONCE_EXPIRY_SEC * 1000,
|
||||
path: '/',
|
||||
});
|
||||
res.redirect(302, url);
|
||||
},
|
||||
);
|
||||
@@ -224,7 +284,7 @@ export class OIDCController extends PuterController {
|
||||
|
||||
const loginCb = async (req: Request, res: Response) => {
|
||||
const origin = this.config.origin ?? '';
|
||||
const result = await this.#processCallback(req, 'login');
|
||||
const result = await this.#processCallback(req, res, 'login');
|
||||
if ('error' in result) {
|
||||
console.warn(`OIDC login callback error: ${result.error}`);
|
||||
return res.redirect(
|
||||
@@ -255,8 +315,9 @@ export class OIDCController extends PuterController {
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
resolved.error,
|
||||
resolved.code ?? 'unauthorized',
|
||||
stateDecoded,
|
||||
resolved.requestCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -272,7 +333,7 @@ export class OIDCController extends PuterController {
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
'This account is suspended.',
|
||||
'account_suspended',
|
||||
stateDecoded,
|
||||
),
|
||||
);
|
||||
@@ -287,7 +348,7 @@ export class OIDCController extends PuterController {
|
||||
|
||||
const signupCb = async (req: Request, res: Response) => {
|
||||
const origin = this.config.origin ?? '';
|
||||
const result = await this.#processCallback(req, 'signup');
|
||||
const result = await this.#processCallback(req, res, 'signup');
|
||||
if ('error' in result) {
|
||||
return res.redirect(
|
||||
302,
|
||||
@@ -308,14 +369,18 @@ export class OIDCController extends PuterController {
|
||||
(stateDecoded.referrer as string) ?? null,
|
||||
);
|
||||
if ('error' in resolved) {
|
||||
console.warn(
|
||||
`OIDC signup user resolution error: ${resolved.error}`,
|
||||
);
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'signup',
|
||||
'other',
|
||||
'unauthorized',
|
||||
resolved.code ?? 'unauthorized',
|
||||
stateDecoded,
|
||||
resolved.requestCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -352,7 +417,7 @@ export class OIDCController extends PuterController {
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const result = await this.#processCallback(req, 'revalidate');
|
||||
const result = await this.#processCallback(req, res, 'revalidate');
|
||||
if ('error' in result) {
|
||||
res.status(400).send(result.error);
|
||||
return;
|
||||
@@ -451,7 +516,7 @@ if (window.opener) {
|
||||
userinfo: { sub: string; email?: unknown; [k: string]: unknown },
|
||||
referrer?: string | null,
|
||||
): Promise<
|
||||
| { error: string }
|
||||
| { error: string; code?: string; requestCode?: string }
|
||||
| {
|
||||
user: import('../../stores/user/UserStore.js').UserRow;
|
||||
origin: 'linked-sub' | 'linked-email' | 'created';
|
||||
@@ -497,13 +562,18 @@ if (window.opener) {
|
||||
referrer,
|
||||
);
|
||||
if (!outcome.success || !outcome.user) {
|
||||
return { error: outcome.error ?? 'Account creation failed.' };
|
||||
return {
|
||||
error: outcome.error ?? 'Account creation failed.',
|
||||
code: outcome.code,
|
||||
requestCode: outcome.requestCode,
|
||||
};
|
||||
}
|
||||
return { user: outcome.user, origin: 'created' };
|
||||
}
|
||||
|
||||
async #processCallback(
|
||||
req: Request,
|
||||
res: Response,
|
||||
flow: string,
|
||||
): Promise<
|
||||
| { error: string }
|
||||
@@ -527,6 +597,28 @@ if (window.opener) {
|
||||
if (!stateDecoded || !stateDecoded.provider)
|
||||
return { error: 'Invalid or expired state.' };
|
||||
|
||||
// Enforce the browser binding set at /start. Every state minted by
|
||||
// the current /start carries a nonce, so this covers all live flows.
|
||||
// States signed before this shipped have no nonce and pass through
|
||||
// until they expire (STATE_EXPIRY, 10 min) so in-flight logins don't
|
||||
// break on deploy — a caller can't forge a nonce-less state because
|
||||
// /start always adds one and the state is server-signed.
|
||||
const expectedNonce =
|
||||
typeof stateDecoded.nonce === 'string' ? stateDecoded.nonce : '';
|
||||
if (expectedNonce) {
|
||||
const cookieNonce = req.cookies?.[OIDC_NONCE_COOKIE_NAME];
|
||||
// Single-use: drop the cookie regardless of the outcome.
|
||||
res.clearCookie(OIDC_NONCE_COOKIE_NAME, { path: '/' });
|
||||
if (
|
||||
typeof cookieNonce !== 'string' ||
|
||||
!constantTimeEqual(cookieNonce, expectedNonce)
|
||||
) {
|
||||
return {
|
||||
error: 'This sign-in could not be verified for your browser. Please start again.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const provider = String(stateDecoded.provider);
|
||||
const callbackUrl = this.services.oidc.getCallbackUrl(flow);
|
||||
if (!callbackUrl) return { error: 'Invalid flow.' };
|
||||
|
||||
@@ -44,7 +44,9 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import type { RouteOptions } from '../../core/http/index.js';
|
||||
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { PuterAIController } from './PuterAIController.js';
|
||||
@@ -192,7 +194,10 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
// call puter-chat-completion directly), but admit the user's own
|
||||
// full-access personal access token: `requireUserActor` keeps apps out
|
||||
// and `allowFullAccessToken` opens the gate to a full-access PAT. Each
|
||||
// proxy route carries both flags.
|
||||
// proxy route carries both flags, plus the shared per-tier AI
|
||||
// rate-limit / concurrency policy — these routes bypass the
|
||||
// `/drivers/call` dispatch (where the driver-declared limits are
|
||||
// enforced), so without the route gates they'd be unthrottled.
|
||||
const userOnlyPaths = [
|
||||
'/puterai/openai/v1/chat/completions',
|
||||
'/puterai/openai/v1/completions',
|
||||
@@ -205,6 +210,16 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: {
|
||||
...AI_RATE_LIMIT.default,
|
||||
scope: 'driver:puter-chat-completion:complete',
|
||||
key: expect.any(Function),
|
||||
},
|
||||
concurrent: {
|
||||
...AI_CONCURRENT.default,
|
||||
scope: 'driver:puter-chat-completion:complete',
|
||||
key: expect.any(Function),
|
||||
},
|
||||
});
|
||||
}
|
||||
const modelsRoute = calls.find(
|
||||
@@ -215,6 +230,46 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
requireAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keys the proxy-route AI limits by user uuid so they share the /drivers/call buckets', () => {
|
||||
const calls: Array<{ path: string; opts: RouteOptions }> = [];
|
||||
const router = {
|
||||
post: vi.fn((path: string, opts: RouteOptions) => {
|
||||
calls.push({ path, opts });
|
||||
return router;
|
||||
}),
|
||||
get: vi.fn(() => router),
|
||||
};
|
||||
|
||||
controller.registerRoutes(router as never);
|
||||
|
||||
const route = calls.find(
|
||||
(c) => c.path === '/puterai/openai/v1/chat/completions',
|
||||
);
|
||||
const rateLimit = route?.opts.rateLimit as {
|
||||
key: (req: Request) => string;
|
||||
scope: string;
|
||||
};
|
||||
const concurrent = route?.opts.concurrent as {
|
||||
key: (req: Request) => string;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
// The dispatch buckets requests as `driver:<iface>:<method>:<uid>`
|
||||
// where uid is the actor's user uuid; scope + key must compose to the
|
||||
// identical string or wire traffic mints a second per-user budget.
|
||||
const req = makeReq({ actor: makeUserActor() });
|
||||
expect(rateLimit.key(req)).toBe('u-7');
|
||||
expect(concurrent.key(req)).toBe('u-7');
|
||||
expect(rateLimit.scope).toBe('driver:puter-chat-completion:complete');
|
||||
expect(concurrent.scope).toBe('driver:puter-chat-completion:complete');
|
||||
|
||||
// No-actor fallback still yields a usable (fingerprint) key rather
|
||||
// than throwing or bucketing everyone together under undefined.
|
||||
const anonKey = rateLimit.key(makeReq({}));
|
||||
expect(typeof anonKey).toBe('string');
|
||||
expect(anonKey.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /openai/v1/chat/completions ─────────────────────────────────────
|
||||
|
||||
@@ -22,6 +22,7 @@ import crypto from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
import { computeNetworkFingerprint } from '../../core/http/middleware/rateLimit.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
|
||||
import type {
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
ICompleteArguments,
|
||||
} from '../../drivers/ai-chat/types.js';
|
||||
import { isDriverStreamResult } from '../../drivers/meta.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
const GEMINI_DOWNLOAD_BASE =
|
||||
@@ -49,6 +51,18 @@ const GEMINI_DOWNLOAD_BASE =
|
||||
*/
|
||||
export class PuterAIController extends PuterController {
|
||||
registerRoutes(router: PuterRouter): void {
|
||||
/**
|
||||
* The wire routes call the chat driver directly instead of going
|
||||
* through the `/drivers/call` dispatch, so the shared per-tier AI
|
||||
* rate-limit / concurrency policy must be declared as route gates
|
||||
* here. `scope` + `key` reproduce the dispatch's bucket key
|
||||
* (`driver:<iface>:<method>:<uid>`) exactly, so wire traffic and
|
||||
* `/drivers/call` traffic draw from one per-user budget rather than
|
||||
* each surface minting its own.
|
||||
*/
|
||||
const aiPolicyScope = 'driver:puter-chat-completion:complete';
|
||||
const aiPolicyKey = (req: Request): string =>
|
||||
req.actor?.user?.uuid || computeNetworkFingerprint(req);
|
||||
const apiAuthOpts = {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
@@ -58,6 +72,16 @@ export class PuterAIController extends PuterController {
|
||||
// personal access tokens (CLI/MCP/scripts). Apps and scoped tokens
|
||||
// stay blocked.
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: {
|
||||
...AI_RATE_LIMIT.default!,
|
||||
scope: aiPolicyScope,
|
||||
key: aiPolicyKey,
|
||||
},
|
||||
concurrent: {
|
||||
...AI_CONCURRENT.default!,
|
||||
scope: aiPolicyScope,
|
||||
key: aiPolicyKey,
|
||||
},
|
||||
} as RouteOptions;
|
||||
const publicOpts = { subdomain: 'api', requireAuth: false } as const;
|
||||
|
||||
|
||||
@@ -37,17 +37,33 @@ export class SystemController extends PuterController {
|
||||
) {
|
||||
// -- Healthcheck ---------------------------------------------
|
||||
// Delegates to ServerHealthService for the real check-based
|
||||
// status. Returns `{ ok: true }` when all registered checks pass,
|
||||
// or `{ ok: false, failed: [...] }` + 503 when any fail or the
|
||||
// status. Returns `{ ok: true }` + 200 when all registered checks
|
||||
// pass, or `{ ok: false, failed: [...] }` + 503 when any fail or the
|
||||
// server is draining.
|
||||
router.get('/healthcheck', { subdomain: '*' }, async (_req, res) => {
|
||||
//
|
||||
// `?ignore=a,b` disregards the named checks for this request only.
|
||||
// `?marked-degraded=a,b` demotes the named checks to a non-fatal
|
||||
// `degraded` list: `ok` stays true but the response is 207 so the
|
||||
// caller can tell the node is running in a degraded state.
|
||||
const parseNames = (value) =>
|
||||
typeof value === 'string'
|
||||
? value
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
router.get('/healthcheck', { subdomain: '*' }, async (req, res) => {
|
||||
const health = this.services.health;
|
||||
if (!health || typeof health.getStatus !== 'function') {
|
||||
// Fallback for boot ordering / missing service.
|
||||
return res.send('ok');
|
||||
}
|
||||
const status = await health.getStatus();
|
||||
const status = await health.getStatus({
|
||||
ignore: parseNames(req.query.ignore),
|
||||
degrade: parseNames(req.query['marked-degraded']),
|
||||
});
|
||||
if (!status.ok) return res.status(503).json(status);
|
||||
if (status.degraded?.length) return res.status(207).json(status);
|
||||
return res.json(status);
|
||||
});
|
||||
|
||||
@@ -137,6 +153,7 @@ export class SystemController extends PuterController {
|
||||
name: 'Puter',
|
||||
version: this.config.version ?? null,
|
||||
environment: this.config.env ?? 'prod',
|
||||
disable_user_signup: Boolean(this.config.disable_user_signup),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { kv } from '../../util/kvSingleton.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
@@ -79,10 +80,11 @@ interface CapturedResponse {
|
||||
const makeReq = (init: {
|
||||
body?: unknown;
|
||||
actor?: Actor;
|
||||
query?: Record<string, unknown>;
|
||||
}): Request => {
|
||||
return {
|
||||
body: init.body ?? {},
|
||||
query: {},
|
||||
query: init.query ?? {},
|
||||
headers: {},
|
||||
actor: init.actor,
|
||||
} as unknown as Request;
|
||||
@@ -139,6 +141,191 @@ describe('SystemController GET /healthcheck', () => {
|
||||
expect(captured.body).toMatchObject({ ok: true });
|
||||
expect(captured.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('parses ?ignore and ?marked-degraded into trimmed name lists', async () => {
|
||||
const spy = vi
|
||||
.spyOn(server.services.health, 'getStatus')
|
||||
.mockResolvedValue({ ok: true });
|
||||
try {
|
||||
const { res } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/healthcheck',
|
||||
makeReq({
|
||||
query: {
|
||||
ignore: 'database-liveness, thumbnailer',
|
||||
'marked-degraded': ' socket-initialized ',
|
||||
},
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
ignore: ['database-liveness', 'thumbnailer'],
|
||||
degrade: ['socket-initialized'],
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns ok:true + 200 when the only failures are ignored', async () => {
|
||||
const spy = vi
|
||||
.spyOn(server.services.health, 'getStatus')
|
||||
.mockImplementation(async ({ ignore = [] } = {}) => {
|
||||
const failed = ['database-liveness'].filter(
|
||||
(name) => !ignore.includes(name),
|
||||
);
|
||||
return failed.length === 0
|
||||
? { ok: true }
|
||||
: { ok: false, failed };
|
||||
});
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/healthcheck',
|
||||
makeReq({ query: { ignore: 'database-liveness' } }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({ ok: true });
|
||||
expect(captured.statusCode).toBe(200);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns ok:true + 207 when the only failures are marked degraded', async () => {
|
||||
const spy = vi
|
||||
.spyOn(server.services.health, 'getStatus')
|
||||
.mockResolvedValue({ ok: true, degraded: ['database-liveness'] });
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/healthcheck',
|
||||
makeReq({ query: { 'marked-degraded': 'database-liveness' } }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({
|
||||
ok: true,
|
||||
degraded: ['database-liveness'],
|
||||
});
|
||||
expect(captured.statusCode).toBe(207);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('still 503s when a non-ignored failure remains', async () => {
|
||||
const spy = vi
|
||||
.spyOn(server.services.health, 'getStatus')
|
||||
.mockImplementation(async ({ ignore = [] } = {}) => {
|
||||
const failed = ['database-liveness', 'socket-initialized'].filter(
|
||||
(name) => !ignore.includes(name),
|
||||
);
|
||||
return failed.length === 0
|
||||
? { ok: true }
|
||||
: { ok: false, failed };
|
||||
});
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/healthcheck',
|
||||
makeReq({ query: { ignore: 'database-liveness' } }),
|
||||
res,
|
||||
);
|
||||
expect(captured.statusCode).toBe(503);
|
||||
expect(captured.body).toEqual({
|
||||
ok: false,
|
||||
failed: ['socket-initialized'],
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── ServerHealthService.getStatus ignore / degrade filtering ────────
|
||||
//
|
||||
// Exercises the real service by seeding the in-process status cache
|
||||
// (the kv.js singleton) it reads from, so the actual per-request
|
||||
// classification runs — not a stubbed getStatus.
|
||||
|
||||
describe('ServerHealthService.getStatus ignore/degrade filtering', () => {
|
||||
const STATUS_CACHE_KEY = 'server-health:status';
|
||||
|
||||
const seedStatus = (status: unknown) => {
|
||||
kv.set(STATUS_CACHE_KEY, status, { EX: 5 });
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
kv.del(STATUS_CACHE_KEY);
|
||||
});
|
||||
|
||||
it('collapses to ok:true when every failure is ignored', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness', 'thumbnailer'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['database-liveness', 'thumbnailer'],
|
||||
});
|
||||
expect(status).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('keeps the non-ignored failures', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness', 'thumbnailer'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({ ok: false, failed: ['thumbnailer'] });
|
||||
});
|
||||
|
||||
it('is a no-op for a healthy status', async () => {
|
||||
seedStatus({ ok: true });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('ignores unknown names without affecting real failures', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['not-a-check'],
|
||||
});
|
||||
expect(status).toEqual({ ok: false, failed: ['database-liveness'] });
|
||||
});
|
||||
|
||||
it('demotes marked failures to degraded and stays ok:true', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
degrade: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({ ok: true, degraded: ['database-liveness'] });
|
||||
});
|
||||
|
||||
it('reports degraded alongside remaining hard failures (ok:false)', async () => {
|
||||
seedStatus({
|
||||
ok: false,
|
||||
failed: ['database-liveness', 'socket-initialized'],
|
||||
});
|
||||
const status = await server.services.health.getStatus({
|
||||
degrade: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({
|
||||
ok: false,
|
||||
failed: ['socket-initialized'],
|
||||
degraded: ['database-liveness'],
|
||||
});
|
||||
});
|
||||
|
||||
it('lets ignore take precedence over degrade for the same name', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['database-liveness'],
|
||||
degrade: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── /version ────────────────────────────────────────────────────────
|
||||
@@ -233,6 +420,7 @@ describe('SystemController GET /whoarewe', () => {
|
||||
expect(captured.body).toMatchObject({
|
||||
name: 'Puter',
|
||||
environment: 'dev',
|
||||
disable_user_signup: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -335,6 +335,63 @@ describe('WebDAVController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('suspension gate', () => {
|
||||
// WebDAV must also enforce the suspension gate every other authenticated
|
||||
// route gets from requireAuthGate. Same single-use() dispatch means that
|
||||
// middleware is never wired in, so #dispatch calls assertNotSuspended
|
||||
// itself. Without it, a suspended account could read/write/delete its
|
||||
// whole filesystem over the `dav` subdomain.
|
||||
it('rejects a suspended session actor with 403', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
actor: {
|
||||
user: {
|
||||
id: 1,
|
||||
uuid: 'suspended-uuid',
|
||||
username: 'suspended',
|
||||
suspended: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a suspended user on the Basic-auth path with 403', async () => {
|
||||
const username = `webdav-suspended-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: await bcryptHash('correct-horse', 4),
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await server.stores.user.update(created.id, {
|
||||
suspended: 1,
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
authorization: basicAuth(username, 'correct-horse'),
|
||||
},
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported methods', () => {
|
||||
it('returns 405 for unknown HTTP methods', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -23,7 +23,10 @@ import { posix as pathPosix } from 'node:path';
|
||||
import { EventMap } from '../../clients/event/types.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { assertVerifiedAccount } from '../../core/http/middleware/gates.js';
|
||||
import {
|
||||
assertNotSuspended,
|
||||
assertVerifiedAccount,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { verify as verifyOtp } from '../../services/auth/OTPUtil.js';
|
||||
import { expandTildePath } from '../../services/fs/resolveNode.js';
|
||||
@@ -90,14 +93,15 @@ export class WebDAVController extends PuterController {
|
||||
const actor = await this.#resolveActor(req, res);
|
||||
if (!actor) return; // 401 already sent
|
||||
|
||||
// Apply the same pending-verification gate every other authenticated
|
||||
// route gets from `requireVerifiedAccount`. WebDAV dispatches every
|
||||
// method off a single `router.use` with no route options, so that
|
||||
// middleware is never inserted into its chain — without this call an
|
||||
// account still pending email / phone / card verification could read,
|
||||
// write, and delete its entire filesystem over the `dav` subdomain,
|
||||
// bypassing the gate. Throws a 403 HttpError, surfaced by the catch in
|
||||
// registerRoutes.
|
||||
// Apply the same suspension + pending-verification gates every other
|
||||
// authenticated route gets from `requireAuthGate` / `requireVerifiedAccount`.
|
||||
// WebDAV dispatches every method off a single `router.use` with no route
|
||||
// options, so that middleware is never inserted into its chain — without
|
||||
// these calls a suspended account (or one still pending email / phone /
|
||||
// card verification) could read, write, and delete its entire filesystem
|
||||
// over the `dav` subdomain, bypassing the gates. Both throw a 403
|
||||
// HttpError, surfaced by the catch in registerRoutes.
|
||||
assertNotSuspended(actor.user);
|
||||
assertVerifiedAccount(actor.user);
|
||||
|
||||
// Expand `~`/`~/...` against the authenticated actor's username.
|
||||
|
||||
@@ -54,9 +54,11 @@ export interface Actor {
|
||||
* Session reference when authenticated via a session token (user actors)
|
||||
* or an app-under-user token that carries a session. Absent for system,
|
||||
* raw-app, and pure access-token actors. Used for session introspection
|
||||
* and targeted logout.
|
||||
* and targeted logout. `kind` mirrors the session row's kind (e.g.
|
||||
* 'web', 'app', 'worker') so callers can gate on how the credential
|
||||
* was minted without an extra session lookup.
|
||||
*/
|
||||
session?: { uid: string } | null;
|
||||
session?: { uid: string; kind?: string | null } | null;
|
||||
}
|
||||
|
||||
/** UUID of the baked-in system user (see 0025 seed migration). */
|
||||
|
||||
@@ -42,6 +42,13 @@ declare global {
|
||||
|
||||
tokenAuthFailed?: boolean;
|
||||
|
||||
/**
|
||||
* Set when a token authenticated but its app is on the origin
|
||||
* blocklist. The auth probe leaves `actor` unset; gates translate
|
||||
* this into a 403 `app_blocked`.
|
||||
*/
|
||||
appBlocked?: { reason?: string };
|
||||
|
||||
requiresReauth?: {
|
||||
reason: 'token_v1' | 'session_revoked' | 'session_expired';
|
||||
auth_id?: string;
|
||||
|
||||
@@ -545,30 +545,10 @@ describe('createAuthProbe — actor attachment + failure tracking', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Reauth signal + KV counters ─────────────────────────────────────
|
||||
// ── Reauth signal ───────────────────────────────────────────────────
|
||||
|
||||
describe('createAuthProbe — reauth signal', () => {
|
||||
/** Capture KV increments without a real store. */
|
||||
const makeKvStub = () => {
|
||||
const calls: Array<{
|
||||
key: string;
|
||||
pathAndAmountMap: Record<string, number>;
|
||||
}> = [];
|
||||
return {
|
||||
calls,
|
||||
store: {
|
||||
incr: async (args: {
|
||||
key: string;
|
||||
pathAndAmountMap: Record<string, number>;
|
||||
}) => {
|
||||
calls.push(args);
|
||||
return { res: {} as Record<string, number>, usage: 0 };
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it('sets requiresReauth and counts v1 + reauth.token_v1 for a legacy v1 token', async () => {
|
||||
it('sets requiresReauth with a signed token for a legacy v1 token', async () => {
|
||||
const stub = makeStubAuth();
|
||||
stub.setNextResult({
|
||||
reauth: { reason: 'token_v1', auth_id: 'u-legacy' },
|
||||
@@ -576,11 +556,7 @@ describe('createAuthProbe — reauth signal', () => {
|
||||
// The probe must still set requiresReauth; gate emits 401.
|
||||
actor: { user: { uuid: 'u-legacy' } },
|
||||
} as never);
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer v1-tok' } }),
|
||||
@@ -590,109 +566,36 @@ describe('createAuthProbe — reauth signal', () => {
|
||||
auth_id: 'u-legacy',
|
||||
reauth_token: 'reauth-jwt:u-legacy',
|
||||
});
|
||||
// Both increments fire under the same day-bucketed key.
|
||||
expect(kv.calls).toHaveLength(1);
|
||||
expect(kv.calls[0].key).toMatch(/^auth-v2:metrics:\d{4}-\d{2}-\d{2}$/);
|
||||
expect(kv.calls[0].pathAndAmountMap).toEqual({
|
||||
v1: 1,
|
||||
'reauth.token_v1': 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets requiresReauth and counts reauth.session_revoked', async () => {
|
||||
it('sets requiresReauth for a revoked session', async () => {
|
||||
const stub = makeStubAuth();
|
||||
stub.setNextResult({
|
||||
reauth: { reason: 'session_revoked', auth_id: 'u-1' },
|
||||
});
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer tok' } }),
|
||||
);
|
||||
expect(req.requiresReauth?.reason).toBe('session_revoked');
|
||||
expect(kv.calls[0].pathAndAmountMap).toEqual({
|
||||
v1: 1,
|
||||
'reauth.session_revoked': 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets requiresReauth and counts reauth.session_expired', async () => {
|
||||
it('sets requiresReauth for an expired session', async () => {
|
||||
const stub = makeStubAuth();
|
||||
stub.setNextResult({
|
||||
reauth: { reason: 'session_expired', auth_id: 'u-1' },
|
||||
});
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer tok' } }),
|
||||
);
|
||||
expect(req.requiresReauth?.reason).toBe('session_expired');
|
||||
expect(kv.calls[0].pathAndAmountMap).toEqual({
|
||||
v1: 1,
|
||||
'reauth.session_expired': 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts v2 verify (no reauth) for a healthy token', async () => {
|
||||
it('attaches an actor and no reauth for a healthy v2 token', async () => {
|
||||
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
|
||||
);
|
||||
expect(req.actor).toBeTruthy();
|
||||
expect(req.requiresReauth).toBeUndefined();
|
||||
expect(kv.calls[0].pathAndAmountMap).toEqual({ v2: 1 });
|
||||
});
|
||||
|
||||
it('does not increment counters when no token is presented', async () => {
|
||||
const stub = makeStubAuth();
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
await runProbe(probe, makeReq({}));
|
||||
// Anonymous-OK routes pay no metrics cost.
|
||||
expect(kv.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('absorbs KV failures — never rejects on the hot path', async () => {
|
||||
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
|
||||
// Failing KV: increments throw. Probe must still succeed.
|
||||
const failingKv = {
|
||||
incr: async () => {
|
||||
throw new Error('kv unavailable');
|
||||
},
|
||||
};
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: failingKv,
|
||||
});
|
||||
const { req, next } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
|
||||
);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.actor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('works without a kvStore (counters become no-ops)', async () => {
|
||||
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
|
||||
// Production may not always pass a KV — fresh installs without
|
||||
// dynamo wired still need a functioning probe.
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
@@ -702,6 +605,17 @@ describe('createAuthProbe — reauth signal', () => {
|
||||
expect(req.requiresReauth).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never rejects on the hot path', async () => {
|
||||
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req, next } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
|
||||
);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.actor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('logs `[auth-v2] reauth reason=<r> auth_id=<id>` per event', async () => {
|
||||
// The log line is the human-facing forensic counterpart to the
|
||||
// KV counter. Asserting the exact shape so ops can `grep
|
||||
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
AuthService,
|
||||
ReauthReason,
|
||||
} from '../../../services/auth/AuthService';
|
||||
import type { SystemKVStore } from '../../../stores/systemKv/SystemKVStore';
|
||||
|
||||
// Ensure the `Request.actor` / `Request.token` augmentation is in scope
|
||||
// wherever this middleware is imported.
|
||||
@@ -32,25 +31,8 @@ interface AuthProbeOptions {
|
||||
authService: AuthService;
|
||||
/** Name of the session cookie to inspect. Falls back to `config.cookie_name`. */
|
||||
cookieName?: string;
|
||||
kvStore?: SystemKVStore;
|
||||
}
|
||||
|
||||
const authV2MetricsKey = (): string => {
|
||||
const now = new Date();
|
||||
const yyyy = now.getUTCFullYear();
|
||||
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(now.getUTCDate()).padStart(2, '0');
|
||||
return `auth-v2:metrics:${yyyy}-${mm}-${dd}`;
|
||||
};
|
||||
|
||||
const bumpCounter = (
|
||||
kv: Pick<SystemKVStore, 'incr'> | undefined,
|
||||
pathAndAmountMap: Record<string, number>,
|
||||
): void => {
|
||||
if (!kv) return;
|
||||
kv.incr({ key: authV2MetricsKey(), pathAndAmountMap }).catch(() => {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Non-enforcing auth probe. Runs globally (installed by `PuterServer`) on
|
||||
* every request, tries to locate a token in the usual places, and — if one
|
||||
@@ -70,7 +52,7 @@ const bumpCounter = (
|
||||
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
|
||||
*/
|
||||
export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
const { authService, cookieName, kvStore } = opts;
|
||||
const { authService, cookieName } = opts;
|
||||
return async (req, _res, next): Promise<void> => {
|
||||
// If something upstream already attached an actor, respect it.
|
||||
if (req.actor) {
|
||||
@@ -94,10 +76,6 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
});
|
||||
|
||||
if (result.reauth) {
|
||||
bumpCounter(kvStore, {
|
||||
v1: 1,
|
||||
[`reauth.${result.reauth.reason}`]: 1,
|
||||
});
|
||||
// Bind a short-lived JWT proving the rejected session
|
||||
// identified this auth_id. The GUI echoes this back on
|
||||
// /login or /signup; the raw auth_id is informational
|
||||
@@ -113,8 +91,13 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
console.info(
|
||||
`[auth-v2] reauth reason=${result.reauth.reason} auth_id=${result.reauth.auth_id ?? '-'}`,
|
||||
);
|
||||
} else if (result.actor) {
|
||||
bumpCounter(kvStore, { v2: 1 });
|
||||
}
|
||||
|
||||
if (result.blocked) {
|
||||
// App is on the origin blocklist: leave `actor` unset so gates
|
||||
// reject. `appBlocked` lets the gate emit a clear 403 instead
|
||||
// of the generic "token failed" 401.
|
||||
req.appBlocked = { reason: result.blocked.reason };
|
||||
}
|
||||
|
||||
if (result.actor) {
|
||||
|
||||
@@ -55,13 +55,15 @@ function readDeviceFingerprint(req: {
|
||||
* Stamp request-scoped fingerprints onto `req` so any downstream gate, handler,
|
||||
* or service (via the ALS `Context.get('req')`) can read them without recomputing:
|
||||
*
|
||||
* - `req.networkFingerprint` — always set; a coarse IP+headers hash (the same
|
||||
* value the rate limiter keys on). Server-derived, so it can't be forged
|
||||
* away, but it's coarse (shared behind NAT/VPN, rotates with UA).
|
||||
* - `req.networkFingerprint` — always set; a coarse IP+headers hash (the
|
||||
* anchor of the rate limiter's default key). Server-derived, so it can't be
|
||||
* forged away, but it's coarse (shared behind NAT/VPN, rotates with UA).
|
||||
* - `req.deviceFingerprint` — set only when the client supplied a well-shaped
|
||||
* device fingerprint (ThumbmarkJS hash) in the body or the
|
||||
* `x-puter-device-fingerprint` header; `undefined` otherwise. Client-supplied
|
||||
* and spoofable, but stable per real device across IP rotation.
|
||||
* and spoofable, but stable per real device across IP rotation. The rate
|
||||
* limiter's 'fingerprint' strategy appends it to the network hash so each
|
||||
* device behind a shared network gets its own bucket.
|
||||
*
|
||||
* Install AFTER the body parsers (so the body fingerprint is readable) and
|
||||
* before `requestContext` (so the snapshot into ALS already carries them).
|
||||
|
||||
@@ -407,6 +407,113 @@ describe('adminOnlyGate', () => {
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- Root-token requirement --
|
||||
//
|
||||
// Admin endpoints require a root token (an actor with no app anywhere
|
||||
// in its token chain), so a third-party app an admin authorized can't
|
||||
// reach them on the admin's behalf.
|
||||
|
||||
it('admits an admin acting via a session (root token)', () => {
|
||||
const got = runGate(adminOnlyGate(), {
|
||||
actor: { user: { uuid: 'u-1', username: 'admin' } },
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it("admits an admin's full-access PAT (still a root token — no app)", () => {
|
||||
const got = runGate(adminOnlyGate(), {
|
||||
actor: {
|
||||
user: { uuid: 'u-1', username: 'admin' },
|
||||
accessToken: {
|
||||
uid: 'tok-1',
|
||||
issuer: { user: { uuid: 'u-1', username: 'admin' } },
|
||||
fullAccess: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects an admin acting through an app with 403 (not a root token)', () => {
|
||||
const got = runGate(adminOnlyGate(), {
|
||||
actor: {
|
||||
user: { uuid: 'u-1', username: 'admin' },
|
||||
app: { uid: 'app-1' },
|
||||
},
|
||||
});
|
||||
expectHttpError(got, 403, 'forbidden');
|
||||
});
|
||||
|
||||
it('rejects an admin access token issued through an app (app in the token chain)', () => {
|
||||
// Access-token actors carry their app on `accessToken.issuer.app`,
|
||||
// not top-level `actor.app` — the root-token check must walk the
|
||||
// chain, not just the top level.
|
||||
const got = runGate(adminOnlyGate(), {
|
||||
actor: {
|
||||
user: { uuid: 'u-1', username: 'admin' },
|
||||
accessToken: {
|
||||
uid: 'tok-1',
|
||||
issuer: {
|
||||
user: { uuid: 'u-1', username: 'admin' },
|
||||
app: { uid: 'app-1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expectHttpError(got, 403, 'forbidden');
|
||||
});
|
||||
|
||||
it('rejects an app-issued access token even when appGated', () => {
|
||||
// The appGated deferral only applies to direct app-under-user
|
||||
// actors: `allowedAppIdsGate` reads top-level `actor.app` and would
|
||||
// pass a chain-only app straight through, so it must not be
|
||||
// deferred to.
|
||||
const got = runGate(adminOnlyGate([], { appGated: true }), {
|
||||
actor: {
|
||||
user: { uuid: 'u-1', username: 'admin' },
|
||||
accessToken: {
|
||||
uid: 'tok-1',
|
||||
issuer: {
|
||||
user: { uuid: 'u-1', username: 'admin' },
|
||||
app: { uid: 'app-1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expectHttpError(got, 403, 'forbidden');
|
||||
});
|
||||
|
||||
it('admits an admin acting through an app when appGated (allowedAppIdsGate then decides)', () => {
|
||||
// On an appId-gated route the root-token check is deferred to
|
||||
// `allowedAppIdsGate`; this gate must let the app actor through.
|
||||
const got = runGate(adminOnlyGate([], { appGated: true }), {
|
||||
actor: {
|
||||
user: { uuid: 'u-1', username: 'admin' },
|
||||
app: { uid: 'app-1' },
|
||||
},
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still admits a root token when appGated', () => {
|
||||
const got = runGate(adminOnlyGate([], { appGated: true }), {
|
||||
actor: { user: { uuid: 'u-1', username: 'admin' } },
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies the username check before the root-token check', () => {
|
||||
// A non-admin acting through an app is rejected for being non-admin,
|
||||
// regardless of the app scope.
|
||||
const got = runGate(adminOnlyGate(), {
|
||||
actor: {
|
||||
user: { uuid: 'u-1', username: 'random-user' },
|
||||
app: { uid: 'app-1' },
|
||||
},
|
||||
});
|
||||
expectHttpError(got, 403, 'forbidden');
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireVerifiedGate ─────────────────────────────────────────────
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { effectiveActorApp } from '../../actor';
|
||||
import { HttpError } from '../HttpError';
|
||||
import { assertVerifiedEmail } from '../verifiedEmail';
|
||||
|
||||
@@ -79,16 +80,24 @@ export const subdomainGate = (allowed: string | string[]): RequestHandler => {
|
||||
*/
|
||||
export const requireAuthGate = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
if (req.appBlocked) {
|
||||
next(
|
||||
new HttpError(
|
||||
403,
|
||||
'This app is not allowed to access Puter resources',
|
||||
{ legacyCode: 'app_blocked' },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!req.actor) {
|
||||
next(rejectAuth(req));
|
||||
return;
|
||||
}
|
||||
if (req.actor.user.suspended) {
|
||||
next(
|
||||
new HttpError(403, 'Account suspended', {
|
||||
legacyCode: 'forbidden',
|
||||
}),
|
||||
);
|
||||
try {
|
||||
assertNotSuspended(req.actor.user);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
@@ -174,13 +183,24 @@ export const DEFAULT_ADMIN_USERNAMES = ['admin', 'system'] as const;
|
||||
* the supplied extras. Extras are *additional* allowed users on top of the
|
||||
* built-in pair, not a replacement for it.
|
||||
*
|
||||
* Implies `requireAuth`. Does *not* imply `requireUserActor` — admin
|
||||
* endpoints are callable via an admin's access token or app-under-user
|
||||
* actor; combine with `requireUserActor` explicitly if a route must be
|
||||
* restricted to browser sessions.
|
||||
* Also requires a *root token* — an actor with no app anywhere in its token
|
||||
* chain (see `effectiveActorApp`) — so a third-party app an admin has
|
||||
* authorized can't reach admin endpoints on the admin's behalf. The one
|
||||
* exception is `appGated`: on a route that is also appId-gated
|
||||
* (`allowedAppIds`), a direct app-under-user actor is deferred to
|
||||
* `allowedAppIdsGate`, so the net effect there is "a root token OR a token
|
||||
* scoped to an allowed app". Access tokens issued through an app are
|
||||
* rejected even then — `allowedAppIdsGate` only sees top-level `actor.app`
|
||||
* and would otherwise wave them through.
|
||||
*
|
||||
* Implies `requireAuth`. Does *not* imply `requireUserActor` — a root token
|
||||
* still includes an admin's full-access personal access token, not only
|
||||
* browser sessions; combine with `requireUserActor` explicitly if a route
|
||||
* must be restricted to browser sessions.
|
||||
*/
|
||||
export const adminOnlyGate = (
|
||||
extras: readonly string[] = [],
|
||||
opts: { appGated?: boolean } = {},
|
||||
): RequestHandler => {
|
||||
// Match the case-insensitivity guarantee of the username column
|
||||
// (MySQL: ascii_general_ci; SQLite: idx_user_username_nocase). Comparing
|
||||
@@ -199,6 +219,20 @@ export const adminOnlyGate = (
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Root-token requirement: reject actors carrying an app anywhere in
|
||||
// their token chain — app-under-user, or an access token issued
|
||||
// through an app. A direct app-under-user actor is deferred to
|
||||
// `allowedAppIdsGate` when the route is appId-gated; chain-only apps
|
||||
// are rejected even then, since that gate can't see them.
|
||||
const chainApp = req.actor ? effectiveActorApp(req.actor) : null;
|
||||
if (chainApp && !(opts.appGated && req.actor?.app?.uid)) {
|
||||
next(
|
||||
new HttpError(403, 'Only admins may request this resource', {
|
||||
legacyCode: 'forbidden',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
@@ -302,6 +336,16 @@ export const assertVerifiedAccount = (
|
||||
}
|
||||
};
|
||||
|
||||
export const assertNotSuspended = (
|
||||
user: { suspended?: unknown } | undefined,
|
||||
): void => {
|
||||
if (user?.suspended) {
|
||||
throw new HttpError(403, 'Account suspended', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject unless the actor is acting through one of the named apps.
|
||||
* App-under-user actors are permitted iff `actor.app.uid` is in the allowList;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import type { RequestHandler } from 'express';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { puterClients } from '../../../clients';
|
||||
import type { puterServices } from '../../../services';
|
||||
import type { puterStores } from '../../../stores';
|
||||
import type { IConfig, LayerInstances } from '../../../types';
|
||||
|
||||
interface Layers {
|
||||
clients: LayerInstances<typeof puterClients>;
|
||||
stores: LayerInstances<typeof puterStores>;
|
||||
services: LayerInstances<typeof puterServices>;
|
||||
}
|
||||
|
||||
// Local analogue of the production `<name>.puter.work` worker domain. Requests
|
||||
// to `<name>.workers.puter.localhost` are dispatched into a Miniflare instance
|
||||
// by `LocalWorkerService`, which mirrors the real Cloudflare dispatch path.
|
||||
const WORKER_HOST_SUFFIX = 'workers.puter.localhost';
|
||||
|
||||
// Minimal WHATWG-Response shape we consume from Miniflare's `dispatchFetch`.
|
||||
// It isn't the Node global `Response`, so we type it structurally rather than
|
||||
// importing Miniflare's classes into the HTTP layer.
|
||||
interface FetchResponse {
|
||||
status: number;
|
||||
headers: { forEach(cb: (value: string, key: string) => void): void };
|
||||
body: ReadableStream<Uint8Array> | null;
|
||||
}
|
||||
|
||||
function normalizeHost(value: string | undefined | null): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim().toLowerCase().replace(/^\./, '');
|
||||
if (!trimmed) return null;
|
||||
return trimmed.split(':')[0] || null;
|
||||
}
|
||||
|
||||
// `<name>.workers.puter.localhost` → `name`. Returns null for the bare zone or
|
||||
// any host outside it. Flip this if your dev DNS puts the name elsewhere.
|
||||
function workerNameFromHost(host: string): string | null {
|
||||
if (host === WORKER_HOST_SUFFIX) return null;
|
||||
if (!host.endsWith(`.${WORKER_HOST_SUFFIX}`)) return null;
|
||||
const prefix = host.slice(0, host.length - WORKER_HOST_SUFFIX.length - 1);
|
||||
return prefix.split('.')[0] || null;
|
||||
}
|
||||
|
||||
// Express (Node) request → WHATWG Request the Worker's `fetch(request)` sees.
|
||||
// Must run BEFORE any body-parsing middleware so `req` is still an unconsumed
|
||||
// stream; otherwise the Worker gets an empty body on POST/PUT.
|
||||
function toFetchRequest(req: Parameters<RequestHandler>[0]): Request {
|
||||
const url = `http://${req.headers.host ?? WORKER_HOST_SUFFIX}${req.originalUrl}`;
|
||||
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) headers.append(key, v);
|
||||
} else if (value != null) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const method = (req.method ?? 'GET').toUpperCase();
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
|
||||
return new Request(url, {
|
||||
method,
|
||||
headers,
|
||||
// `duplex: 'half'` is required by undici whenever a stream body is set.
|
||||
body: hasBody ? (Readable.toWeb(req) as ReadableStream) : undefined,
|
||||
...(hasBody ? { duplex: 'half' } : {}),
|
||||
} as RequestInit);
|
||||
}
|
||||
|
||||
// WHATWG Response from the Worker → Express response.
|
||||
function sendFetchResponse(
|
||||
res: Parameters<RequestHandler>[1],
|
||||
response: FetchResponse,
|
||||
): void {
|
||||
res.status(response.status);
|
||||
response.headers.forEach((value, key) => {
|
||||
// Node manages framing headers itself; forwarding them corrupts the
|
||||
// response (double content-length, stale transfer-encoding).
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === 'content-length' || lower === 'transfer-encoding') return;
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
|
||||
if (!response.body) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeStream = Readable.fromWeb(response.body as never);
|
||||
nodeStream.on('error', () => res.destroy());
|
||||
nodeStream.pipe(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves local Workers on `*.workers.puter.localhost` by dispatching into
|
||||
* Miniflare via `LocalWorkerService`. No-op unless `config.workers.localServer`
|
||||
* is set — production keeps hitting real Cloudflare through `WorkerDriver`.
|
||||
*
|
||||
* Mount this BEFORE the body-parsing middleware in `server.ts` so the Worker
|
||||
* receives the raw request stream.
|
||||
*/
|
||||
export const createLocalWorkerProxyMiddleware = (
|
||||
config: IConfig,
|
||||
layers: Layers,
|
||||
): RequestHandler => {
|
||||
if (!config.workers?.localServer) {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
|
||||
const localWorkerService = layers.services.localworkerservice;
|
||||
|
||||
return async (req, res, next) => {
|
||||
const host = normalizeHost(req.hostname);
|
||||
if (!host) return next();
|
||||
|
||||
const workerName = workerNameFromHost(host);
|
||||
if (!workerName) return next();
|
||||
|
||||
try {
|
||||
const fetchRequest = toFetchRequest(req);
|
||||
const response = (await localWorkerService.cfCallLocal(
|
||||
workerName,
|
||||
fetchRequest,
|
||||
)) as unknown as FetchResponse;
|
||||
sendFetchResponse(res, response);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { withSpan } from '../../../util/span.js';
|
||||
import { HttpError } from '../HttpError.js';
|
||||
|
||||
/**
|
||||
@@ -256,8 +257,32 @@ async function acquireKvConcurrent(kv, key, limit) {
|
||||
// limiting — so route / driver code never has to reason about which
|
||||
// backend is wired for which mode.
|
||||
|
||||
/**
|
||||
* Wrap a backend pair so every rate / acquire call runs inside a span
|
||||
* tagged with the backend name. Applied at registration, so all gates
|
||||
* (route middleware, driver helpers, imperative checks) are covered.
|
||||
*/
|
||||
function instrumentBackendPair(name, pair) {
|
||||
const attrs = { 'rate_limit.backend': name };
|
||||
return {
|
||||
rate: (key, limit, windowMs) =>
|
||||
withSpan('rate_limit.check', attrs, () =>
|
||||
pair.rate(key, limit, windowMs),
|
||||
),
|
||||
acquire: (key, limit) =>
|
||||
withSpan('rate_limit.acquire', attrs, () =>
|
||||
pair.acquire(key, limit),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const memoryBackendPair = instrumentBackendPair('memory', {
|
||||
rate: checkMemory,
|
||||
acquire: acquireMemoryConcurrent,
|
||||
});
|
||||
|
||||
const backends = {
|
||||
memory: { rate: checkMemory, acquire: acquireMemoryConcurrent },
|
||||
memory: memoryBackendPair,
|
||||
};
|
||||
let defaultBackendName = 'memory';
|
||||
|
||||
@@ -295,19 +320,19 @@ export function configureRateLimit({
|
||||
} = {}) {
|
||||
// Reset (test reconfigure clears stale wiring).
|
||||
for (const name of Object.keys(backends)) delete backends[name];
|
||||
backends.memory = { rate: checkMemory, acquire: acquireMemoryConcurrent };
|
||||
backends.memory = memoryBackendPair;
|
||||
if (redis) {
|
||||
backends.redis = {
|
||||
backends.redis = instrumentBackendPair('redis', {
|
||||
rate: (key, limit, windowMs) =>
|
||||
checkRedis(redis, key, limit, windowMs),
|
||||
acquire: (key, limit) => acquireRedisConcurrent(redis, key, limit),
|
||||
};
|
||||
});
|
||||
}
|
||||
if (kv) {
|
||||
backends.kv = {
|
||||
backends.kv = instrumentBackendPair('kv', {
|
||||
rate: (key, limit, windowMs) => checkKv(kv, key, limit, windowMs),
|
||||
acquire: (key, limit) => acquireKvConcurrent(kv, key, limit),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
meteringService = metering ?? null;
|
||||
@@ -351,9 +376,10 @@ function resolveBackend(name) {
|
||||
* Build a rate-limit key from the request.
|
||||
*
|
||||
* Strategies:
|
||||
* 'fingerprint' — IP + User-Agent hash (default). Good for
|
||||
* unauthenticated endpoints where the same IP may
|
||||
* serve many users (offices, VPNs).
|
||||
* 'fingerprint' — network hash (IP + headers), refined by the client's
|
||||
* device fingerprint when one was supplied (default).
|
||||
* Good for unauthenticated endpoints where the same
|
||||
* IP may serve many users (offices, VPNs).
|
||||
* 'ip' — bare IP. Simpler but coarser.
|
||||
* 'user' — actor UUID. Use for authenticated endpoints where
|
||||
* you want per-account limits regardless of IP.
|
||||
@@ -395,7 +421,7 @@ function ip(req) {
|
||||
/**
|
||||
* A coarse network fingerprint for a request: a short hash of the (proxy-aware)
|
||||
* IP plus the headers a client can't trivially vary per-request without also
|
||||
* changing how the request looks. Used as the default rate-limit key here, and
|
||||
* changing how the request looks. Anchors the default rate-limit key here, and
|
||||
* exported so the global fingerprint middleware can stamp the identical value
|
||||
* on `req.networkFingerprint` (one key space shared by both).
|
||||
*/
|
||||
@@ -413,8 +439,19 @@ export function computeNetworkFingerprint(req) {
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* The device fingerprint (validated and stamped by the fingerprint middleware)
|
||||
* refines the bucket so devices behind one NAT don't crowd each other's limit.
|
||||
* It stays anchored to the network hash because the value is client-supplied:
|
||||
* alone it could be spoofed to drain another device's bucket, and rotating it
|
||||
* to mint fresh buckets is caught by the same stacked 'ip' backstop that
|
||||
* catches User-Agent rotation.
|
||||
*/
|
||||
function fingerprint(req) {
|
||||
return computeNetworkFingerprint(req);
|
||||
const network = req.networkFingerprint ?? computeNetworkFingerprint(req);
|
||||
return req.deviceFingerprint
|
||||
? `${network}:${req.deviceFingerprint}`
|
||||
: network;
|
||||
}
|
||||
|
||||
// -- Route middleware ------------------------------------------------
|
||||
|
||||
@@ -67,6 +67,7 @@ describe('rateLimitGate — memory backend (default)', () => {
|
||||
actor: init.actor,
|
||||
route: init.route,
|
||||
socket: init.socket ?? { remoteAddress: '1.2.3.4' },
|
||||
deviceFingerprint: init.deviceFingerprint,
|
||||
});
|
||||
|
||||
it('admits up to `limit` hits and rejects the next one with 429', async () => {
|
||||
@@ -140,6 +141,62 @@ describe('rateLimitGate — memory backend (default)', () => {
|
||||
expect(isHttpError(re)).toBe(true);
|
||||
});
|
||||
|
||||
it("'fingerprint' (default) gives each device behind one network its own bucket", async () => {
|
||||
// Same IP and identical headers (a NAT'd office of look-alike
|
||||
// machines) but distinct device fingerprints (stamped by the
|
||||
// fingerprint middleware) → independent buckets, so one device
|
||||
// hitting its limit doesn't block the whole network.
|
||||
const opts = { limit: 1, window: 60_000, scope: 'mem-fp-device' };
|
||||
const shared = {
|
||||
ip: '5.6.7.8',
|
||||
headers: { 'user-agent': 'shared-browser' },
|
||||
};
|
||||
expect(
|
||||
await runGate(
|
||||
opts,
|
||||
makeReq({ ...shared, deviceFingerprint: 'device-alice-01' }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
await runGate(
|
||||
opts,
|
||||
makeReq({ ...shared, deviceFingerprint: 'device-bob-02' }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
// Same device again → its own bucket is full.
|
||||
const re = await runGate(
|
||||
opts,
|
||||
makeReq({ ...shared, deviceFingerprint: 'device-alice-01' }),
|
||||
);
|
||||
expect(isHttpError(re)).toBe(true);
|
||||
});
|
||||
|
||||
it('device fingerprint stays anchored to the network — spoofing a value from another IP cannot drain its bucket', async () => {
|
||||
const opts = { limit: 1, window: 60_000, scope: 'mem-fp-anchor' };
|
||||
const fp = 'device-victim-01';
|
||||
// Victim exhausts their bucket from their own network.
|
||||
expect(
|
||||
await runGate(
|
||||
opts,
|
||||
makeReq({ ip: '10.0.0.1', deviceFingerprint: fp }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
const re = await runGate(
|
||||
opts,
|
||||
makeReq({ ip: '10.0.0.1', deviceFingerprint: fp }),
|
||||
);
|
||||
expect(isHttpError(re)).toBe(true);
|
||||
// Attacker replays the same device fingerprint from elsewhere:
|
||||
// different network hash → different bucket, and the victim's
|
||||
// bucket is untouched.
|
||||
expect(
|
||||
await runGate(
|
||||
opts,
|
||||
makeReq({ ip: '99.99.99.99', deviceFingerprint: fp }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
// Stacked gates, mirroring the materializer's handling of a
|
||||
// `rateLimit: [...]` array (each entry is its own gate; a rejection
|
||||
// short-circuits the chain). This is the credential-endpoint pattern:
|
||||
|
||||
@@ -121,9 +121,15 @@ export interface RouteOptions {
|
||||
* extras in this array. `true` means just `admin`/`system`; an array adds
|
||||
* to that pair (does not replace it). Implies `requireAuth`.
|
||||
*
|
||||
* Does NOT imply `requireUserActor` — admin endpoints accept an admin's
|
||||
* access-token or app-under-user actor. Combine with `requireUserActor`
|
||||
* to restrict to browser sessions.
|
||||
* Also requires a *root token* (an actor with no app anywhere in its
|
||||
* token chain), so an admin acting through a third-party app can't reach
|
||||
* the route. Pair with `allowedAppIds` to make an admin route reachable
|
||||
* by specific apps: the combination admits a root token OR a token
|
||||
* scoped to an allowed app.
|
||||
*
|
||||
* Does NOT imply `requireUserActor` — a root token still includes an
|
||||
* admin's full-access personal access token, not only browser sessions.
|
||||
* Combine with `requireUserActor` to restrict to browser sessions.
|
||||
*/
|
||||
adminOnly?: boolean | string[];
|
||||
|
||||
@@ -210,8 +216,10 @@ export interface RouteOptions {
|
||||
* request identity.
|
||||
*
|
||||
* `key` controls how requests are bucketed:
|
||||
* - `'fingerprint'` (default) — IP + User-Agent hash. Safe for
|
||||
* shared IPs (offices, VPNs).
|
||||
* - `'fingerprint'` (default) — network hash (IP + headers),
|
||||
* refined by the client's device fingerprint when one was
|
||||
* supplied. Safe for shared IPs (offices, VPNs): each device
|
||||
* gets its own bucket instead of the whole network sharing one.
|
||||
* - `'ip'` — bare IP address.
|
||||
* - `'user'` — actor's user ID. Use for authenticated routes
|
||||
* where you want per-account limits.
|
||||
|
||||
@@ -268,6 +268,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
// Other models require temperature=1 when thinking is enabled.
|
||||
const omitsTemperature = [
|
||||
'claude-fable-5',
|
||||
'claude-sonnet-5',
|
||||
'claude-opus-4-7',
|
||||
'claude-opus-4-8',
|
||||
].includes(modelUsed.id);
|
||||
@@ -278,6 +279,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
: (temperature ?? 0);
|
||||
const supportsEffort = [
|
||||
'claude-fable-5',
|
||||
'claude-sonnet-5',
|
||||
'claude-opus-4-8',
|
||||
'claude-opus-4-7',
|
||||
'claude-opus-4-6',
|
||||
|
||||
@@ -50,6 +50,35 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
context: 1000000,
|
||||
max_tokens: 128000,
|
||||
},
|
||||
{
|
||||
puterId: 'anthropic:anthropic/claude-sonnet-5',
|
||||
id: 'claude-sonnet-5',
|
||||
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
release_date: '2026-06-30',
|
||||
aliases: [
|
||||
'claude-sonnet',
|
||||
'claude-sonnet-latest',
|
||||
'claude-sonnet-5-latest',
|
||||
'claude-sonnet-5',
|
||||
'anthropic/claude-sonnet-5',
|
||||
],
|
||||
name: 'Claude Sonnet 5',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'input_tokens',
|
||||
output_cost_key: 'output_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
input_tokens: 300,
|
||||
ephemeral_5m_input_tokens: 300 * 1.25,
|
||||
ephemeral_1h_input_tokens: 300 * 2,
|
||||
cache_read_input_tokens: 300 * 0.1,
|
||||
output_tokens: 1500,
|
||||
},
|
||||
context: 1000000,
|
||||
max_tokens: 64000,
|
||||
},
|
||||
{
|
||||
puterId: 'anthropic:anthropic/claude-opus-4-8',
|
||||
id: 'claude-opus-4-8',
|
||||
@@ -119,8 +148,6 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
knowledge: '2025-08',
|
||||
release_date: '2026-02-17',
|
||||
aliases: [
|
||||
'claude-sonnet-latest',
|
||||
'claude-sonnet',
|
||||
'claude-sonnet-4-6-latest',
|
||||
'claude-sonnet-4.6',
|
||||
'claude-sonnet-4-6',
|
||||
|
||||
@@ -23,6 +23,74 @@ import type { IChatModel } from '../../types.js';
|
||||
|
||||
// Hardcoded from https://models.dev/api.json
|
||||
export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.6-sol',
|
||||
id: 'gpt-5.6-sol',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2026-02-16',
|
||||
aliases: [
|
||||
'gpt-5.6',
|
||||
'gpt-5.6-sol',
|
||||
'openai/gpt-5.6',
|
||||
'openai/gpt-5.6-sol',
|
||||
],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 500,
|
||||
cached_tokens: 50,
|
||||
completion_tokens: 3000,
|
||||
},
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api_only: true,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.6-terra',
|
||||
id: 'gpt-5.6-terra',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2026-02-16',
|
||||
aliases: ['gpt-5.6-terra', 'openai/gpt-5.6-terra'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 250,
|
||||
cached_tokens: 25,
|
||||
completion_tokens: 1500,
|
||||
},
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api_only: true,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.6-luna',
|
||||
id: 'gpt-5.6-luna',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2026-02-16',
|
||||
aliases: ['gpt-5.6-luna', 'openai/gpt-5.6-luna'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 100,
|
||||
cached_tokens: 10,
|
||||
completion_tokens: 600,
|
||||
},
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api_only: true,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.5',
|
||||
id: 'gpt-5.5-2026-04-23',
|
||||
|
||||
@@ -21,6 +21,27 @@ import type { IChatModel } from '../../types.js';
|
||||
|
||||
// Hardcoded from https://models.dev/api.json
|
||||
export const XAI_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'x-ai:x-ai/grok-4.5',
|
||||
id: 'grok-4.5',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
release_date: '2026-07-08',
|
||||
name: 'Grok 4.5',
|
||||
aliases: ['x-ai/grok-4.5', 'grok-4.5-latest'],
|
||||
context: 500_000,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 600,
|
||||
cached_tokens: 50,
|
||||
},
|
||||
max_tokens: 30_000,
|
||||
},
|
||||
{
|
||||
puterId: 'x-ai:x-ai/grok-beta',
|
||||
id: 'grok-beta',
|
||||
|
||||
@@ -55,20 +55,30 @@ import type { ImageGenerationDriver } from './ImageGenerationDriver.js';
|
||||
// driver-level tests — what we care about is the driver's routing.
|
||||
// Each `generate` mock resolves to a sentinel URL the test inspects.
|
||||
|
||||
const { openaiImagesGenerateMock } = vi.hoisted(() => ({
|
||||
const { openaiImagesGenerateMock, openaiImagesEditMock } = vi.hoisted(() => ({
|
||||
openaiImagesGenerateMock: vi.fn(),
|
||||
openaiImagesEditMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('openai', () => {
|
||||
const OpenAICtor = vi.fn().mockImplementation(function (
|
||||
this: Record<string, unknown>,
|
||||
) {
|
||||
this.images = { generate: openaiImagesGenerateMock };
|
||||
this.images = {
|
||||
generate: openaiImagesGenerateMock,
|
||||
edit: openaiImagesEditMock,
|
||||
};
|
||||
// xAI provider reaches its JSON edit endpoint through the SDK's post().
|
||||
this.post = vi.fn();
|
||||
this.chat = { completions: { create: vi.fn() } };
|
||||
this.moderations = { create: vi.fn() };
|
||||
this.responses = { create: vi.fn() };
|
||||
});
|
||||
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
|
||||
return {
|
||||
OpenAI: OpenAICtor,
|
||||
default: { OpenAI: OpenAICtor },
|
||||
toFile: vi.fn(async () => ({ __file: true })),
|
||||
};
|
||||
});
|
||||
|
||||
const { googleAIGenerateContentMock, googleAIGenerateImagesMock } = vi.hoisted(
|
||||
@@ -148,6 +158,7 @@ afterAll(async () => {
|
||||
|
||||
beforeEach(() => {
|
||||
openaiImagesGenerateMock.mockReset();
|
||||
openaiImagesEditMock.mockReset();
|
||||
googleAIGenerateContentMock.mockReset();
|
||||
googleAIGenerateImagesMock.mockReset();
|
||||
togetherImagesGenerateMock.mockReset();
|
||||
@@ -176,9 +187,9 @@ describe('ImageGenerationDriver.generate authentication', () => {
|
||||
it('throws 401 when no actor is on the request context', async () => {
|
||||
await expect(
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
@@ -206,10 +217,10 @@ describe('ImageGenerationDriver model catalog', () => {
|
||||
const all = await driver.models();
|
||||
// Every catalog id from at least one provider must be reachable.
|
||||
const ids = all.map((m) => m.id);
|
||||
// OpenAI catalog: dall-e-2 should be present (lowercased by buildModelMap).
|
||||
expect(ids).toContain('dall-e-2');
|
||||
// xAI catalog: grok-2-image should be present.
|
||||
expect(ids).toContain('grok-2-image');
|
||||
// OpenAI catalog: gpt-image-1-mini should be present (lowercased by buildModelMap).
|
||||
expect(ids).toContain('gpt-image-1-mini');
|
||||
// xAI catalog: grok-imagine-image should be present.
|
||||
expect(ids).toContain('grok-imagine-image');
|
||||
});
|
||||
|
||||
it('list() returns ids/puterIds sorted', async () => {
|
||||
@@ -224,32 +235,35 @@ describe('ImageGenerationDriver model catalog', () => {
|
||||
costValue: number;
|
||||
source: string;
|
||||
}>;
|
||||
// dall-e-2 has a 256x256 cost line — must surface in reportedCosts.
|
||||
const dalle256 = reported.find(
|
||||
(r) => r.usageType === 'openai-image-generation:dall-e-2:256x256',
|
||||
// gpt-image-1-mini has a low:1024x1024 cost line — must surface in reportedCosts.
|
||||
const gptLine = reported.find(
|
||||
(r) =>
|
||||
r.usageType ===
|
||||
'openai-image-generation:gpt-image-1-mini:low:1024x1024',
|
||||
);
|
||||
expect(dalle256).toBeDefined();
|
||||
expect(dalle256?.costValue).toBe(
|
||||
OPEN_AI_IMAGE_GENERATION_MODELS.find((m) => m.id === 'dall-e-2')!
|
||||
.costs['256x256'],
|
||||
expect(gptLine).toBeDefined();
|
||||
expect(gptLine?.costValue).toBe(
|
||||
OPEN_AI_IMAGE_GENERATION_MODELS.find(
|
||||
(m) => m.id === 'gpt-image-1-mini',
|
||||
)!.costs['low:1024x1024'],
|
||||
);
|
||||
expect(dalle256?.source).toBe('driver:aiImage/openai-image-generation');
|
||||
expect(gptLine?.source).toBe('driver:aiImage/openai-image-generation');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Provider routing ────────────────────────────────────────────────
|
||||
|
||||
describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
it('routes a known dall-e-2 model id to the OpenAI image provider', async () => {
|
||||
it('routes a known gpt-image-1-mini model id to the OpenAI image provider', async () => {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://oai/img.png' }],
|
||||
});
|
||||
|
||||
const result = await withActor(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
);
|
||||
|
||||
@@ -260,34 +274,34 @@ describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
expect(replicateRunMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes a known grok-2-image id to the xAI image provider (also OpenAI-SDK shaped)', async () => {
|
||||
it('routes a known grok-imagine-image id to the xAI image provider (also OpenAI-SDK shaped)', async () => {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://xai/img.png' }],
|
||||
});
|
||||
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
model: 'grok-2-image',
|
||||
model: 'grok-imagine-image',
|
||||
prompt: 'hi',
|
||||
} as never),
|
||||
);
|
||||
|
||||
// xAI's provider also uses the OpenAI mock — assert via the call args.
|
||||
const sent = openaiImagesGenerateMock.mock.calls[0]![0];
|
||||
expect(sent.model).toBe('grok-2-image');
|
||||
expect(sent.model).toBe('grok-imagine-image');
|
||||
expect(sent.prompt).toBe('hi');
|
||||
});
|
||||
|
||||
it('lowercases model lookups so case variants resolve (DALL-E-2 → dall-e-2)', async () => {
|
||||
it('lowercases model lookups so case variants resolve (GPT-Image-1-Mini → gpt-image-1-mini)', async () => {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://oai/img.png' }],
|
||||
});
|
||||
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
model: 'DALL-E-2',
|
||||
model: 'GPT-Image-1-Mini',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
);
|
||||
|
||||
@@ -322,16 +336,16 @@ describe('ImageGenerationDriver.generate ratio normalization', () => {
|
||||
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
width: 256,
|
||||
height: 256,
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
} as never),
|
||||
);
|
||||
|
||||
// Provider received a size derived from the ratio normalization.
|
||||
const sent = openaiImagesGenerateMock.mock.calls[0]![0];
|
||||
expect(sent.size).toBe('256x256');
|
||||
expect(sent.size).toBe('1024x1536');
|
||||
});
|
||||
|
||||
it('parses aspect_ratio "w:h" into ratio when width/height are absent', async () => {
|
||||
@@ -369,9 +383,9 @@ describe('ImageGenerationDriver.generate audit log', () => {
|
||||
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
);
|
||||
|
||||
@@ -381,7 +395,7 @@ describe('ImageGenerationDriver.generate audit log', () => {
|
||||
expect(aiLogCall).toBeDefined();
|
||||
const [, payload] = aiLogCall!;
|
||||
const p = payload as Record<string, unknown>;
|
||||
expect(p.model_used).toBe('dall-e-2');
|
||||
expect(p.model_used).toBe('gpt-image-1-mini');
|
||||
expect(p.service_used).toBe('openai-image-generation');
|
||||
// completionId is a fresh uuid-style string per call.
|
||||
expect(typeof p.completionId).toBe('string');
|
||||
@@ -395,9 +409,9 @@ describe('ImageGenerationDriver.generate audit log', () => {
|
||||
await expect(
|
||||
withActor(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
@@ -424,7 +438,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/',
|
||||
} as never),
|
||||
@@ -438,7 +452,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/image.png',
|
||||
} as never),
|
||||
@@ -455,7 +469,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/somedir/image.png',
|
||||
} as never),
|
||||
@@ -480,7 +494,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/dir/img.png',
|
||||
} as never),
|
||||
@@ -509,7 +523,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
|
||||
await withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '~/images/out.png',
|
||||
} as never),
|
||||
@@ -541,7 +555,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
|
||||
const result = await withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/photos/out.png',
|
||||
} as never),
|
||||
@@ -584,7 +598,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
|
||||
await withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/dir/img.png',
|
||||
} as never),
|
||||
@@ -602,7 +616,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
Promise.resolve(
|
||||
runWithContext({ actor: noIdActor }, () =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/noone/dir/img.png',
|
||||
} as never),
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shared helpers for `input_images` (image-to-image) handling across image
|
||||
* providers. `input_images` is the canonical, cross-provider field; an entry
|
||||
* may be a public URL, a data-URI, or raw base64. Providers whose upstream
|
||||
* API needs base64 use these helpers to normalize URLs server-side (via the
|
||||
* SSRF-guarded `secureFetch`); providers that accept URLs natively (Replicate,
|
||||
* xAI) pass them through untouched.
|
||||
*/
|
||||
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { secureFetch } from '../../util/secureHttp.js';
|
||||
import type { IGenerateParams } from './types.js';
|
||||
|
||||
export function isHttpUrl(s: string): boolean {
|
||||
return s.startsWith('http://') || s.startsWith('https://');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the single input image for providers that only support one.
|
||||
* Throws 400 if `input_images` carries more than one entry. Returns the
|
||||
* chosen image string (URL / data-URI / raw base64) or undefined.
|
||||
*/
|
||||
export function resolveSingleInputImage(
|
||||
params: Pick<IGenerateParams, 'input_image' | 'input_images'>,
|
||||
providerLabel: string,
|
||||
): string | undefined {
|
||||
const imgs = params.input_images;
|
||||
if (imgs && imgs.length > 1) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`${providerLabel} supports only a single input image; pass one image via input_image or a single-element input_images.`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return params.input_image ?? imgs?.[0];
|
||||
}
|
||||
|
||||
const DATA_URI_PATTERN = /^data:([^;,]+)?(?:;base64)?,(.*)$/s;
|
||||
|
||||
/** Parse a `data:<mime>;base64,<payload>` URI into raw base64 + mime. */
|
||||
export function parseDataUri(
|
||||
s: string,
|
||||
): { base64: string; mime: string } | null {
|
||||
const m = DATA_URI_PATTERN.exec(s);
|
||||
if (!m) return null;
|
||||
return { base64: m[2] ?? '', mime: m[1] ?? 'image/png' };
|
||||
}
|
||||
|
||||
/** Fetch an http(s) image and return raw base64 + mime (SSRF-guarded). */
|
||||
export async function fetchImageAsBase64(
|
||||
url: string,
|
||||
): Promise<{ base64: string; mime: string }> {
|
||||
const res = await secureFetch(url);
|
||||
if (!res.ok) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Failed to fetch input image (status ${res.status})`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const mime =
|
||||
res.headers.get('content-type')?.split(';')[0]?.trim() || 'image/png';
|
||||
return { base64: buffer.toString('base64'), mime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize any input-image string to a base64 data-URI:
|
||||
* • http(s) URL → fetched via secureFetch
|
||||
* • data-URI → returned as-is
|
||||
* • raw base64 → wrapped with `mimeHint` (default image/png)
|
||||
*/
|
||||
export async function toBase64DataUri(
|
||||
img: string,
|
||||
mimeHint?: string,
|
||||
): Promise<string> {
|
||||
if (img.startsWith('data:')) return img;
|
||||
if (isHttpUrl(img)) {
|
||||
const { base64, mime } = await fetchImageAsBase64(img);
|
||||
return `data:${mime};base64,${base64}`;
|
||||
}
|
||||
return `data:${mimeHint ?? 'image/png'};base64,${img}`;
|
||||
}
|
||||
@@ -47,6 +47,16 @@ import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './models.js';
|
||||
import { CloudflareImageProvider } from './CloudflareImageProvider.js';
|
||||
|
||||
// Stub the URL→base64 fetch so URL inputs stay offline; keep the rest real.
|
||||
const { fetchImageAsBase64Mock } = vi.hoisted(() => ({
|
||||
fetchImageAsBase64Mock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../inputImage.js', async (orig) => ({
|
||||
...(await orig<typeof import('../../inputImage.js')>()),
|
||||
fetchImageAsBase64: fetchImageAsBase64Mock,
|
||||
}));
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
@@ -81,6 +91,7 @@ const makeProvider = (
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchImageAsBase64Mock.mockReset();
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
batchIncrementUsagesSpy = vi.spyOn(
|
||||
@@ -470,3 +481,70 @@ describe('CloudflareImageProvider.generate cost components', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── input_images (canonical image-to-image field) ──────────────────
|
||||
|
||||
describe('CloudflareImageProvider.generate input_images', () => {
|
||||
const klein9bWith = (extra: Record<string, unknown>) => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(Buffer.from([1, 2, 3]).buffer, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/png' },
|
||||
}),
|
||||
);
|
||||
return withTestActor(() =>
|
||||
provider.generate({
|
||||
model: '@cf/black-forest-labs/flux-2-klein-9b',
|
||||
prompt: 'edit it',
|
||||
ratio: { w: 2000, h: 1000 },
|
||||
...extra,
|
||||
} as never),
|
||||
);
|
||||
};
|
||||
|
||||
const hasInputCostLine = () => {
|
||||
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
|
||||
return (entries as Array<{ usageType: string }>).some((e) =>
|
||||
e.usageType.endsWith(':input_image_mp'),
|
||||
);
|
||||
};
|
||||
|
||||
it('maps a base64/data-URI input_images entry to the input image (cost line appears)', async () => {
|
||||
await klein9bWith({ input_images: ['data:image/png;base64,AAAA'] });
|
||||
expect(hasInputCostLine()).toBe(true);
|
||||
});
|
||||
|
||||
it('maps a singular input_image to the input image', async () => {
|
||||
await klein9bWith({ input_image: 'data:image/png;base64,AAAA' });
|
||||
expect(hasInputCostLine()).toBe(true);
|
||||
});
|
||||
|
||||
it('fetches an http(s) URL input via secureFetch and uses it as the input image', async () => {
|
||||
fetchImageAsBase64Mock.mockResolvedValueOnce({
|
||||
base64: 'AAAA',
|
||||
mime: 'image/png',
|
||||
});
|
||||
await klein9bWith({ input_images: ['https://example.com/in.png'] });
|
||||
expect(fetchImageAsBase64Mock).toHaveBeenCalledWith(
|
||||
'https://example.com/in.png',
|
||||
);
|
||||
expect(hasInputCostLine()).toBe(true);
|
||||
});
|
||||
|
||||
it('throws 400 when more than one input image is supplied (before any fetch)', async () => {
|
||||
const provider = makeProvider();
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.generate({
|
||||
model: '@cf/black-forest-labs/flux-2-klein-9b',
|
||||
prompt: 'edit it',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
input_images: ['data:image/png;base64,AAAA', 'data:image/png;base64,BBBB'],
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(fetchImageAsBase64Mock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,11 @@ import {
|
||||
CLOUDFLARE_IMAGE_GENERATION_MODELS,
|
||||
CloudflareImageModel,
|
||||
} from './models.js';
|
||||
import {
|
||||
fetchImageAsBase64,
|
||||
isHttpUrl,
|
||||
resolveSingleInputImage,
|
||||
} from '../../inputImage.js';
|
||||
|
||||
type CloudflareGenerateParams = IGenerateParams & {
|
||||
steps?: number;
|
||||
@@ -101,6 +106,16 @@ export class CloudflareImageProvider implements IImageProvider {
|
||||
});
|
||||
}
|
||||
|
||||
// Canonical `input_images`/`input_image` → Cloudflare's `image` field.
|
||||
// Cloudflare accepts a single input image; a URL is fetched to base64
|
||||
// server-side (SSRF-guarded) since the API has no URL field.
|
||||
const singleInput = resolveSingleInputImage(options, 'Cloudflare');
|
||||
if (singleInput) {
|
||||
options.image ??= isHttpUrl(singleInput)
|
||||
? (await fetchImageAsBase64(singleInput)).base64
|
||||
: singleInput;
|
||||
}
|
||||
|
||||
const steps = this.#resolveSteps(selectedModel, options);
|
||||
const costComponents = this.#estimateCost(selectedModel, ratio, steps, {
|
||||
hasInputImage:
|
||||
|
||||
@@ -71,6 +71,16 @@ vi.mock('@google/genai', () => {
|
||||
return { GoogleGenAI };
|
||||
});
|
||||
|
||||
// Stub the URL→data-URI normalizer so URL inputs stay offline; keep the rest real.
|
||||
const { toBase64DataUriMock } = vi.hoisted(() => ({
|
||||
toBase64DataUriMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../inputImage.js', async (orig) => ({
|
||||
...(await orig<typeof import('../../inputImage.js')>()),
|
||||
toBase64DataUri: toBase64DataUriMock,
|
||||
}));
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
@@ -94,6 +104,7 @@ const makeProvider = () =>
|
||||
beforeEach(() => {
|
||||
generateContentMock.mockReset();
|
||||
generateImagesMock.mockReset();
|
||||
toBase64DataUriMock.mockReset();
|
||||
googleAICtor.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
@@ -272,6 +283,31 @@ describe('GeminiImageProvider.generate Flash path (generateContent)', () => {
|
||||
expect(result).toBe('data:image/png;base64,BASE64IMG');
|
||||
});
|
||||
|
||||
it('fetches an http(s) URL input and sends it as an inlineData part', async () => {
|
||||
const provider = makeProvider();
|
||||
generateContentMock.mockResolvedValueOnce(inlineImageResponse);
|
||||
toBase64DataUriMock.mockResolvedValueOnce(
|
||||
'data:image/png;base64,URLBYTES',
|
||||
);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'gemini-2.5-flash-image',
|
||||
prompt: 'add a hat',
|
||||
input_images: ['https://example.com/in.png'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(toBase64DataUriMock).toHaveBeenCalledWith(
|
||||
'https://example.com/in.png',
|
||||
undefined,
|
||||
);
|
||||
const sent = generateContentMock.mock.calls[0]![0];
|
||||
expect(sent.contents).toContainEqual({
|
||||
inlineData: { mimeType: 'image/png', data: 'URLBYTES' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws 400 when the SDK returns no inline image data', async () => {
|
||||
const provider = makeProvider();
|
||||
generateContentMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
IImageModel,
|
||||
IImageProvider,
|
||||
} from '../../types.js';
|
||||
import { isHttpUrl, toBase64DataUri } from '../../inputImage.js';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
|
||||
|
||||
const MIME_SIGNATURES: Record<string, string> = {
|
||||
@@ -107,6 +108,18 @@ export class GeminiImageProvider implements IImageProvider {
|
||||
input_images = [input_image];
|
||||
}
|
||||
|
||||
// Resolve any http(s) URL inputs to base64 data-URIs server-side
|
||||
// (SSRF-guarded) so the rest of the flow only deals with inline data.
|
||||
if (input_images?.length) {
|
||||
input_images = await Promise.all(
|
||||
input_images.map((img) =>
|
||||
isHttpUrl(img)
|
||||
? toBase64DataUri(img, input_image_mime_type)
|
||||
: img,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Validate input images have detectable MIME types
|
||||
if (input_images?.length) {
|
||||
for (const img of input_images) {
|
||||
|
||||
@@ -39,6 +39,8 @@ export const GEMINI_ESTIMATED_IMAGE_TOKENS: Record<string, number> = {
|
||||
'gemini-3.1-flash-image-preview:1K': 1120,
|
||||
'gemini-3.1-flash-image-preview:2K': 1680,
|
||||
'gemini-3.1-flash-image-preview:4K': 2520,
|
||||
|
||||
'gemini-3.1-flash-lite-image:1K': 1120,
|
||||
};
|
||||
|
||||
export const GEMINI_IMAGE_GENERATION_MODELS: IGeminiImageModel[] = [
|
||||
@@ -152,6 +154,43 @@ export const GEMINI_IMAGE_GENERATION_MODELS: IGeminiImageModel[] = [
|
||||
'1K:1x1': 6.7,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-3.1-flash-lite-image',
|
||||
id: 'gemini-3.1-flash-lite-image',
|
||||
name: 'Gemini 3.1 Flash Lite Image',
|
||||
version: '1.0',
|
||||
costs_currency: 'usd-cents',
|
||||
index_cost_key: '1K:1x1',
|
||||
index_input_cost_key: 'input',
|
||||
aliases: [
|
||||
'gemini-3.1-flash-lite-image',
|
||||
'google/gemini-3.1-flash-lite-image',
|
||||
'google:google/gemini-3.1-flash-lite-image',
|
||||
],
|
||||
allowedQualityLevels: ['1K'], // 2K and 4K are unsupported
|
||||
allowedRatios: [
|
||||
{ w: 1, h: 1 },
|
||||
{ w: 1, h: 4 },
|
||||
{ w: 1, h: 8 },
|
||||
{ w: 2, h: 3 },
|
||||
{ w: 3, h: 2 },
|
||||
{ w: 3, h: 4 },
|
||||
{ w: 4, h: 1 },
|
||||
{ w: 4, h: 3 },
|
||||
{ w: 4, h: 5 },
|
||||
{ w: 5, h: 4 },
|
||||
{ w: 8, h: 1 },
|
||||
{ w: 9, h: 16 },
|
||||
{ w: 16, h: 9 },
|
||||
{ w: 21, h: 9 },
|
||||
],
|
||||
costs: {
|
||||
input: 25, // $0.25 per 1M input tokens (text/image)
|
||||
output: 150, // $1.50 per 1M output tokens (text and thinking)
|
||||
output_image: 3000, // $30.00 per 1M output image tokens
|
||||
'1K:1x1': 3.36, // 1120 tokens @ $30/1M = $0.0336 per 1K image
|
||||
},
|
||||
},
|
||||
|
||||
// -- Imagen models (use generateImages API) --
|
||||
{
|
||||
|
||||
+5
-5
@@ -20,8 +20,8 @@
|
||||
/**
|
||||
* Integration test for the OpenAI image generation provider.
|
||||
*
|
||||
* Uses `dall-e-2` at 256x256 — the cheapest OpenAI image
|
||||
* configuration ($0.016/image). Skipped when
|
||||
* Uses `gpt-image-1-mini` at low:1024x1024 — the cheapest OpenAI
|
||||
* image configuration ($0.005/image). Skipped when
|
||||
* `PUTER_TEST_AI_OPENAI_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
@@ -40,7 +40,7 @@ const ENV_VAR = 'PUTER_TEST_AI_OPENAI_API_KEY';
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'OpenAiImageProvider (integration)',
|
||||
() => {
|
||||
it('returns an image url/data from dall-e-2 at 256x256', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
|
||||
it('returns an image url/data from gpt-image-1-mini at low:1024x1024', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
|
||||
const provider = new OpenAiImageProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
@@ -48,9 +48,9 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'a tiny red dot on a white background',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
* redis) and constructs OpenAiImageProvider directly against the
|
||||
* live wired `MeteringService`. The OpenAI SDK is mocked at the
|
||||
* module boundary; that's the real network egress point. Covers the
|
||||
* three model families the provider supports differently: dall-e-2/3
|
||||
* (per-size pricing), gpt-image-* (token-priced), and gpt-image-2
|
||||
* (open-ended size with the runtime-clamping normalizer).
|
||||
* gpt-image-* models (token-priced), gpt-image-2 (open-ended size
|
||||
* with the runtime-clamping normalizer), and image-to-image editing
|
||||
* via `input_images` (the `images.edit` endpoint).
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -50,8 +50,15 @@ import { OpenAiImageProvider } from './OpenAiImageProvider.js';
|
||||
|
||||
// ── OpenAI SDK mock ─────────────────────────────────────────────────
|
||||
|
||||
const { generateMock, openAICtor } = vi.hoisted(() => ({
|
||||
const { generateMock, editMock, toFileMock, openAICtor } = vi.hoisted(() => ({
|
||||
generateMock: vi.fn(),
|
||||
editMock: vi.fn(),
|
||||
toFileMock: vi.fn(async (data: unknown, name: unknown, opts: unknown) => ({
|
||||
__file: true,
|
||||
name,
|
||||
type: (opts as { type?: string } | undefined)?.type,
|
||||
data,
|
||||
})),
|
||||
openAICtor: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -61,12 +68,26 @@ vi.mock('openai', () => {
|
||||
opts: unknown,
|
||||
) {
|
||||
openAICtor(opts);
|
||||
this.images = { generate: generateMock };
|
||||
this.images = { generate: generateMock, edit: editMock };
|
||||
this.chat = { completions: { create: vi.fn() } };
|
||||
});
|
||||
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
|
||||
return {
|
||||
OpenAI: OpenAICtor,
|
||||
default: { OpenAI: OpenAICtor },
|
||||
toFile: toFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Stub the URL→base64 fetch so URL inputs stay offline; keep the rest real.
|
||||
const { fetchImageAsBase64Mock } = vi.hoisted(() => ({
|
||||
fetchImageAsBase64Mock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../inputImage.js', async (orig) => ({
|
||||
...(await orig<typeof import('../../inputImage.js')>()),
|
||||
fetchImageAsBase64: fetchImageAsBase64Mock,
|
||||
}));
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
@@ -88,6 +109,8 @@ const makeProvider = () =>
|
||||
|
||||
beforeEach(() => {
|
||||
generateMock.mockReset();
|
||||
editMock.mockReset();
|
||||
fetchImageAsBase64Mock.mockReset();
|
||||
openAICtor.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
batchIncrementUsagesSpy = vi.spyOn(
|
||||
@@ -113,9 +136,16 @@ describe('OpenAiImageProvider construction', () => {
|
||||
// ── Model catalog ───────────────────────────────────────────────────
|
||||
|
||||
describe('OpenAiImageProvider model catalog', () => {
|
||||
it('returns dall-e-2 as the default', () => {
|
||||
it('returns gpt-image-1-mini as the default', () => {
|
||||
const provider = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe('dall-e-2');
|
||||
expect(provider.getDefaultModel()).toBe('gpt-image-1-mini');
|
||||
});
|
||||
|
||||
it('no longer exposes any dall-e models', () => {
|
||||
const provider = makeProvider();
|
||||
expect(
|
||||
provider.models().some((m) => m.id.startsWith('dall-e')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes the static OPEN_AI_IMAGE_GENERATION_MODELS list verbatim', () => {
|
||||
@@ -151,25 +181,6 @@ describe('OpenAiImageProvider.generate argument validation', () => {
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 when DALL-E model + invalid quality+size combo cannot be priced', async () => {
|
||||
const provider = makeProvider();
|
||||
// dall-e-3 has no `2048x2048` cost entry; allowedRatios will snap to
|
||||
// 1024x1024 first allowed entry though, so to trigger this we use a
|
||||
// model with allowedRatios containing the mismatched ratio. We pass
|
||||
// dall-e-2 with 256x256 but inject a hd quality (no hd:256 cost key).
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'dall-e-2',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
quality: 'hd',
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Credit gate ─────────────────────────────────────────────────────
|
||||
@@ -181,9 +192,9 @@ describe('OpenAiImageProvider.generate credit gate', () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
@@ -191,89 +202,48 @@ describe('OpenAiImageProvider.generate credit gate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── DALL-E request shape & metering ────────────────────────────────
|
||||
// ── Output extraction ──────────────────────────────────────────────
|
||||
|
||||
describe('OpenAiImageProvider.generate DALL-E request shape', () => {
|
||||
const dalleResponse = {
|
||||
data: [{ url: 'https://oai.example/img.png' }],
|
||||
usage: undefined, // DALL-E responses don't include usage details.
|
||||
};
|
||||
|
||||
it('forwards model + size verbatim and snaps invalid ratios to the first allowedRatio', async () => {
|
||||
const provider = makeProvider();
|
||||
generateMock.mockResolvedValueOnce(dalleResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'dall-e-2',
|
||||
prompt: 'a tiny red dot',
|
||||
ratio: { w: 999, h: 999 }, // not in allowedRatios; falls back to 256x256
|
||||
}),
|
||||
);
|
||||
|
||||
const sent = generateMock.mock.calls[0]![0];
|
||||
expect(sent.model).toBe('dall-e-2');
|
||||
expect(sent.size).toBe('256x256');
|
||||
// DALL-E 2 doesn't accept `quality` so the provider must omit it
|
||||
// unless it's the literal 'hd' override (DALL-E 3).
|
||||
expect('quality' in sent).toBe(false);
|
||||
describe('OpenAiImageProvider.generate output extraction', () => {
|
||||
const gptResponse = (data: unknown) => ({
|
||||
data,
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 800,
|
||||
input_tokens_details: {
|
||||
text_tokens: 100,
|
||||
image_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('forwards quality=hd only for DALL-E 3', async () => {
|
||||
it('returns response.data[0].url when the SDK returns a url', async () => {
|
||||
const provider = makeProvider();
|
||||
generateMock.mockResolvedValueOnce(dalleResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'dall-e-3',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
quality: 'hd',
|
||||
}),
|
||||
generateMock.mockResolvedValueOnce(
|
||||
gptResponse([{ url: 'https://oai.example/img.png' }]),
|
||||
);
|
||||
|
||||
const sent = generateMock.mock.calls[0]![0];
|
||||
expect(sent.model).toBe('dall-e-3');
|
||||
expect(sent.quality).toBe('hd');
|
||||
});
|
||||
|
||||
it('returns response.data[0].url and meters one output line at the size cents rate', async () => {
|
||||
const provider = makeProvider();
|
||||
generateMock.mockResolvedValueOnce(dalleResponse);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBe('https://oai.example/img.png');
|
||||
|
||||
// dall-e-2 256x256 cost = 1.6 cents → 1_600_000 microcents.
|
||||
expect(batchIncrementUsagesSpy).toHaveBeenCalledTimes(1);
|
||||
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
|
||||
const outputEntry = (
|
||||
entries as Array<{ usageType: string; costOverride: number }>
|
||||
).find((e) => e.usageType.endsWith(':output'));
|
||||
expect(outputEntry?.usageType).toBe(
|
||||
'openai:dall-e-2:256x256:output',
|
||||
);
|
||||
expect(outputEntry?.costOverride).toBe(Math.ceil(1.6 * 1_000_000));
|
||||
});
|
||||
|
||||
it('falls back to a base64 data URL when SDK returns b64_json instead of url', async () => {
|
||||
const provider = makeProvider();
|
||||
generateMock.mockResolvedValueOnce({
|
||||
data: [{ b64_json: 'AAAA' }],
|
||||
});
|
||||
generateMock.mockResolvedValueOnce(gptResponse([{ b64_json: 'AAAA' }]));
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -281,6 +251,120 @@ describe('OpenAiImageProvider.generate DALL-E request shape', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── input_images / edit endpoint ───────────────────────────────────
|
||||
|
||||
describe('OpenAiImageProvider.generate input_images (edit endpoint)', () => {
|
||||
const editResponse = {
|
||||
data: [{ b64_json: 'AAAA' }],
|
||||
usage: {
|
||||
input_tokens: 600,
|
||||
output_tokens: 800,
|
||||
input_tokens_details: {
|
||||
text_tokens: 40,
|
||||
image_tokens: 560,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PNG = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
|
||||
it('routes input_images to images.edit (not generate) with image files set', async () => {
|
||||
const provider = makeProvider();
|
||||
editMock.mockResolvedValueOnce(editResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'gpt-image-1',
|
||||
prompt: 'add a hat',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
input_images: [PNG, PNG],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
expect(editMock).toHaveBeenCalledTimes(1);
|
||||
const sent = editMock.mock.calls[0]![0];
|
||||
expect(sent.model).toBe('gpt-image-1');
|
||||
expect(sent.prompt).toBe('add a hat');
|
||||
// Two input images → array of uploadables.
|
||||
expect(Array.isArray(sent.image)).toBe(true);
|
||||
expect(sent.image).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('meters an :input line at the image_input token rate when the edit response reports image tokens', async () => {
|
||||
const provider = makeProvider();
|
||||
editMock.mockResolvedValueOnce(editResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'gpt-image-1',
|
||||
prompt: 'add a hat',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
input_images: [PNG],
|
||||
}),
|
||||
);
|
||||
|
||||
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
|
||||
const inputEntry = (
|
||||
entries as Array<{ usageType: string; costOverride: number }>
|
||||
).find((e) => e.usageType.endsWith(':input'));
|
||||
expect(inputEntry?.usageType).toBe(
|
||||
'openai:gpt-image-1:low:1024x1024:input',
|
||||
);
|
||||
// gpt-image-1: text_input=500, image_input=1000 (cents/1M tokens).
|
||||
// 40 text + 560 image tokens → (40*500 + 560*1000)/1e6 cents.
|
||||
const expectedCents = (40 * 500 + 560 * 1000) / 1_000_000;
|
||||
expect(inputEntry?.costOverride).toBe(Math.ceil(expectedCents * 1_000_000));
|
||||
});
|
||||
|
||||
it('folds singular input_image into the edit path with a single uploadable', async () => {
|
||||
const provider = makeProvider();
|
||||
editMock.mockResolvedValueOnce(editResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'add a hat',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
input_image: PNG,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(editMock).toHaveBeenCalledTimes(1);
|
||||
const sent = editMock.mock.calls[0]![0];
|
||||
// Single image → not wrapped in an array.
|
||||
expect(Array.isArray(sent.image)).toBe(false);
|
||||
expect((sent.image as { __file?: boolean }).__file).toBe(true);
|
||||
});
|
||||
|
||||
it('fetches an http(s) URL input and sends the bytes to images.edit', async () => {
|
||||
const provider = makeProvider();
|
||||
editMock.mockResolvedValueOnce(editResponse);
|
||||
fetchImageAsBase64Mock.mockResolvedValueOnce({
|
||||
base64: 'iVBORw0KGgo=',
|
||||
mime: 'image/png',
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'gpt-image-1',
|
||||
prompt: 'add a hat',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
input_images: ['https://example.com/in.png'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fetchImageAsBase64Mock).toHaveBeenCalledWith(
|
||||
'https://example.com/in.png',
|
||||
);
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
expect(editMock).toHaveBeenCalledTimes(1);
|
||||
const sent = editMock.mock.calls[0]![0];
|
||||
expect((sent.image as { __file?: boolean }).__file).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── gpt-image (token-priced) request shape & metering ──────────────
|
||||
|
||||
describe('OpenAiImageProvider.generate gpt-image-* request shape', () => {
|
||||
@@ -423,7 +507,7 @@ describe('OpenAiImageProvider.generate gpt-image-2 size normalizer', () => {
|
||||
|
||||
// ── Output extraction error ────────────────────────────────────────
|
||||
|
||||
describe('OpenAiImageProvider.generate output extraction', () => {
|
||||
describe('OpenAiImageProvider.generate output extraction error', () => {
|
||||
it('throws 400 when SDK returns no usable image data', async () => {
|
||||
const provider = makeProvider();
|
||||
generateMock.mockResolvedValueOnce({ data: [{}] });
|
||||
@@ -431,9 +515,9 @@ describe('OpenAiImageProvider.generate output extraction', () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import openai, { OpenAI } from 'openai';
|
||||
import openai, { OpenAI, toFile } from 'openai';
|
||||
import {
|
||||
ImageEditParamsNonStreaming,
|
||||
ImageGenerateParamsNonStreaming,
|
||||
ImagesResponse,
|
||||
} from 'openai/resources/images.js';
|
||||
@@ -30,6 +31,7 @@ import type {
|
||||
IImageProvider,
|
||||
} from '../../types.js';
|
||||
import { OPEN_AI_IMAGE_GENERATION_MODELS } from './models.js';
|
||||
import { fetchImageAsBase64, isHttpUrl } from '../../inputImage.js';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
|
||||
|
||||
interface OpenAIImageUsage {
|
||||
@@ -44,7 +46,8 @@ interface OpenAIImageUsage {
|
||||
|
||||
/**
|
||||
* OpenAI image generation provider for v2.
|
||||
* Supports DALL-E 2/3 and GPT Image models.
|
||||
* Supports the GPT Image models (gpt-image-1, -1-mini, -1.5, -2), including
|
||||
* image-to-image editing via `input_images` (the `images.edit` endpoint).
|
||||
*/
|
||||
export class OpenAiImageProvider implements IImageProvider {
|
||||
#meteringService: MeteringService;
|
||||
@@ -59,6 +62,11 @@ export class OpenAiImageProvider implements IImageProvider {
|
||||
'image_output',
|
||||
];
|
||||
|
||||
// Rough per-image input token estimate for the up-front credit gate only.
|
||||
// Actual billing uses the real `image_tokens` reported in the response.
|
||||
// Mirrors the constant the Gemini image provider uses.
|
||||
static #ESTIMATED_IMAGE_INPUT_TOKENS = 560;
|
||||
|
||||
constructor(config: { apiKey: string }, meteringService: MeteringService) {
|
||||
this.#meteringService = meteringService;
|
||||
this.#openai = new openai.OpenAI({
|
||||
@@ -71,7 +79,7 @@ export class OpenAiImageProvider implements IImageProvider {
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
return 'dall-e-2';
|
||||
return 'gpt-image-1-mini';
|
||||
}
|
||||
|
||||
async generate({
|
||||
@@ -80,6 +88,9 @@ export class OpenAiImageProvider implements IImageProvider {
|
||||
test_mode,
|
||||
model,
|
||||
ratio,
|
||||
input_image,
|
||||
input_images,
|
||||
input_image_mime_type,
|
||||
}: IGenerateParams) {
|
||||
const selectedModel =
|
||||
this.models().find((m) => m.id === model) ||
|
||||
@@ -89,6 +100,12 @@ export class OpenAiImageProvider implements IImageProvider {
|
||||
return 'https://puter-sample-data.puter.site/image_example.png';
|
||||
}
|
||||
|
||||
// Backwards compat: fold singular `input_image` into `input_images`.
|
||||
if (input_image && (!input_images || input_images.length === 0)) {
|
||||
input_images = [input_image];
|
||||
}
|
||||
const hasInputImages = (input_images?.length ?? 0) > 0;
|
||||
|
||||
if (typeof prompt !== 'string') {
|
||||
throw new HttpError(400, '`prompt` must be a string', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -146,12 +163,17 @@ export class OpenAiImageProvider implements IImageProvider {
|
||||
|
||||
const estimatedPromptTokenCount =
|
||||
this.#estimatePromptTokenCount(prompt);
|
||||
const estimatedImageInputTokens = hasInputImages
|
||||
? (input_images?.length ?? 0) *
|
||||
OpenAiImageProvider.#ESTIMATED_IMAGE_INPUT_TOKENS
|
||||
: 0;
|
||||
const estimatedInputCostInCents = this.#calculateInputCostInCents(
|
||||
selectedModel,
|
||||
{
|
||||
inputTokens: estimatedPromptTokenCount,
|
||||
inputTokens:
|
||||
estimatedPromptTokenCount + estimatedImageInputTokens,
|
||||
inputTextTokens: estimatedPromptTokenCount,
|
||||
inputImageTokens: 0,
|
||||
inputImageTokens: estimatedImageInputTokens,
|
||||
cachedInputTokens: 0,
|
||||
cachedInputTextTokens: 0,
|
||||
cachedInputImageTokens: 0,
|
||||
@@ -174,15 +196,25 @@ export class OpenAiImageProvider implements IImageProvider {
|
||||
);
|
||||
}
|
||||
|
||||
// Build API parameters based on model
|
||||
const apiParams = this.#buildApiParams(selectedModel.id, {
|
||||
user: userIdentifier,
|
||||
prompt,
|
||||
size,
|
||||
quality,
|
||||
} as Partial<ImageGenerateParamsNonStreaming>);
|
||||
|
||||
const result = await this.#openai.images.generate(apiParams);
|
||||
// With input images we use the edit endpoint (gpt-image only);
|
||||
// otherwise the standard generate endpoint.
|
||||
const result = hasInputImages
|
||||
? await this.#openai.images.edit(
|
||||
await this.#buildEditParams(
|
||||
selectedModel.id,
|
||||
{ user: userIdentifier, prompt, size, quality },
|
||||
input_images!,
|
||||
input_image_mime_type,
|
||||
),
|
||||
)
|
||||
: await this.#openai.images.generate(
|
||||
this.#buildApiParams(selectedModel.id, {
|
||||
user: userIdentifier,
|
||||
prompt,
|
||||
size,
|
||||
quality,
|
||||
} as Partial<ImageGenerateParamsNonStreaming>),
|
||||
);
|
||||
|
||||
const usage = this.#extractUsage(result);
|
||||
const hasInputTokenUsage =
|
||||
@@ -585,39 +617,81 @@ export class OpenAiImageProvider implements IImageProvider {
|
||||
}
|
||||
|
||||
#buildPriceKey(model: string, quality: string, size: string) {
|
||||
if (this.#isGptImageModel(model)) {
|
||||
// GPT image models use format: "quality:size" - default to low if not specified
|
||||
const qualityLevel = quality || 'low';
|
||||
return `${qualityLevel}:${size}`;
|
||||
}
|
||||
|
||||
// DALL-E models use format: "hd:size" or just "size"
|
||||
return (quality === 'hd' ? 'hd:' : '') + size;
|
||||
// All supported models are gpt-image-*, which price by "quality:size".
|
||||
const qualityLevel = quality || 'low';
|
||||
return `${qualityLevel}:${size}`;
|
||||
}
|
||||
|
||||
#buildApiParams(
|
||||
model: string,
|
||||
baseParams: Partial<ImageGenerateParamsNonStreaming>,
|
||||
): ImageGenerateParamsNonStreaming {
|
||||
const apiParams = {
|
||||
return {
|
||||
model,
|
||||
user: baseParams.user,
|
||||
prompt: baseParams.prompt,
|
||||
size: baseParams.size,
|
||||
// Default to low quality if not specified, consistent with #buildPriceKey.
|
||||
quality: baseParams.quality || 'low',
|
||||
} as ImageGenerateParamsNonStreaming;
|
||||
}
|
||||
|
||||
if (this.#isGptImageModel(model)) {
|
||||
// GPT image models require the model parameter and use quality mapping
|
||||
apiParams.model = model;
|
||||
// Default to low quality if not specified, consistent with _buildPriceKey
|
||||
apiParams.quality = baseParams.quality || 'low';
|
||||
async #buildEditParams(
|
||||
model: string,
|
||||
baseParams: {
|
||||
user?: string;
|
||||
prompt?: string;
|
||||
size?: string;
|
||||
quality?: string;
|
||||
},
|
||||
input_images: string[],
|
||||
mimeHint?: string,
|
||||
): Promise<ImageEditParamsNonStreaming> {
|
||||
const files = await Promise.all(
|
||||
input_images.map((img) => this.#toUploadable(img, mimeHint)),
|
||||
);
|
||||
return {
|
||||
model,
|
||||
user: baseParams.user,
|
||||
prompt: baseParams.prompt,
|
||||
size: baseParams.size,
|
||||
quality: baseParams.quality || 'low',
|
||||
// gpt-image accepts one image or an array of images.
|
||||
image: files.length === 1 ? files[0] : files,
|
||||
} as ImageEditParamsNonStreaming;
|
||||
}
|
||||
|
||||
// Accepts a public URL, a `data:<mime>;base64,...` URI, or a raw base64
|
||||
// string and turns it into an uploadable file for the OpenAI edit endpoint.
|
||||
// URLs are fetched server-side via the SSRF-guarded secureFetch.
|
||||
async #toUploadable(img: string, mimeHint?: string) {
|
||||
let mime = mimeHint ?? 'image/png';
|
||||
let base64 = img;
|
||||
|
||||
if (isHttpUrl(img)) {
|
||||
const fetched = await fetchImageAsBase64(img);
|
||||
mime = fetched.mime;
|
||||
base64 = fetched.base64;
|
||||
} else {
|
||||
// dall-e models
|
||||
apiParams.model = model;
|
||||
if (baseParams.quality === 'hd') {
|
||||
apiParams.quality = 'hd';
|
||||
const dataUri = /^data:([^;]+);base64,(.*)$/s.exec(img);
|
||||
if (dataUri) {
|
||||
mime = dataUri[1];
|
||||
base64 = dataUri[2];
|
||||
}
|
||||
}
|
||||
|
||||
return apiParams;
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
if (buffer.length === 0) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Invalid input image (empty or not base64)',
|
||||
{
|
||||
legacyCode: 'bad_request',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const ext = mime.split('/')[1]?.split('+')[0] || 'png';
|
||||
return toFile(buffer, `image.${ext}`, { type: mime });
|
||||
}
|
||||
}
|
||||
@@ -144,46 +144,4 @@ export const OPEN_AI_IMAGE_GENERATION_MODELS: IImageModel[] = [
|
||||
{ w: 1536, h: 1024 },
|
||||
],
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/dall-e-3',
|
||||
id: 'dall-e-3',
|
||||
aliases: ['openai/dall-e-3'],
|
||||
name: 'DALL-E 3',
|
||||
version: '1.0',
|
||||
costs_currency: 'usd-cents',
|
||||
index_cost_key: '1024x1024',
|
||||
costs: {
|
||||
'1024x1024': 4,
|
||||
'1024x1792': 8,
|
||||
'1792x1024': 8,
|
||||
'hd:1024x1024': 8,
|
||||
'hd:1024x1792': 12,
|
||||
'hd:1792x1024': 12,
|
||||
},
|
||||
allowedQualityLevels: ['', 'hd'],
|
||||
allowedRatios: [
|
||||
{ w: 1024, h: 1024 },
|
||||
{ w: 1024, h: 1792 },
|
||||
{ w: 1792, h: 1024 },
|
||||
],
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/dall-e-2',
|
||||
id: 'dall-e-2',
|
||||
aliases: ['openai/dall-e-2'],
|
||||
name: 'DALL-E 2',
|
||||
version: '1.0',
|
||||
costs_currency: 'usd-cents',
|
||||
index_cost_key: '1024x1024',
|
||||
costs: {
|
||||
'256x256': 1.6,
|
||||
'512x512': 1.8,
|
||||
'1024x1024': 2,
|
||||
},
|
||||
allowedRatios: [
|
||||
{ w: 256, h: 256 },
|
||||
{ w: 512, h: 512 },
|
||||
{ w: 1024, h: 1024 },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -332,6 +332,55 @@ describe('TogetherImageProvider.generate request shape', () => {
|
||||
const sent = generateMock.mock.calls[0]![0];
|
||||
expect(sent.image_base64).toBe('BASE64DATA');
|
||||
});
|
||||
|
||||
it('routes a base64 input_images entry to image_base64', async () => {
|
||||
const provider = makeProvider();
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'togetherai:black-forest-labs/FLUX.1-schnell',
|
||||
prompt: 'edit it',
|
||||
input_images: ['BASE64DATA'],
|
||||
}),
|
||||
);
|
||||
|
||||
const sent = generateMock.mock.calls[0]![0];
|
||||
expect(sent.image_base64).toBe('BASE64DATA');
|
||||
});
|
||||
|
||||
it('routes a URL input_images entry to the native image_url field (no fetch)', async () => {
|
||||
const provider = makeProvider();
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'togetherai:black-forest-labs/FLUX.1-schnell',
|
||||
prompt: 'edit it',
|
||||
input_images: ['https://example.com/in.png'],
|
||||
}),
|
||||
);
|
||||
|
||||
const sent = generateMock.mock.calls[0]![0];
|
||||
expect(sent.image_url).toBe('https://example.com/in.png');
|
||||
expect(sent.image_base64).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws 400 when more than one input image is supplied', async () => {
|
||||
const provider = makeProvider();
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'togetherai:black-forest-labs/FLUX.1-schnell',
|
||||
prompt: 'edit it',
|
||||
input_images: ['BASE64A', 'BASE64B'],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Output extraction & error mapping ───────────────────────────────
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
IImageProvider,
|
||||
} from '../../types.js';
|
||||
import { TOGETHER_IMAGE_GENERATION_MODELS } from './models.js';
|
||||
import { isHttpUrl, resolveSingleInputImage } from '../../inputImage.js';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
|
||||
|
||||
const TOGETHER_DEFAULT_RATIO = { w: 1024, h: 1024 };
|
||||
@@ -87,6 +88,18 @@ export class TogetherImageProvider implements IImageProvider {
|
||||
});
|
||||
}
|
||||
|
||||
// Canonical `input_images` → Together's native fields. Together accepts
|
||||
// a single input image: a URL goes to `image_url`, base64/data-URI to
|
||||
// `image_base64` (via the existing `input_image` alias).
|
||||
const singleInput = resolveSingleInputImage(params, 'Together AI');
|
||||
if (singleInput) {
|
||||
if (isHttpUrl(singleInput)) {
|
||||
options.image_url ??= singleInput;
|
||||
} else {
|
||||
options.input_image ??= singleInput;
|
||||
}
|
||||
}
|
||||
|
||||
ratio = ratio || TOGETHER_DEFAULT_RATIO;
|
||||
|
||||
const actor = Context.get('actor');
|
||||
|
||||
@@ -488,6 +488,16 @@ export const TOGETHER_IMAGE_GENERATION_MODELS: IImageModel[] = [
|
||||
pricing_unit: 'per-MP',
|
||||
costs: { '1MP': 6 },
|
||||
},
|
||||
{
|
||||
id: 'togetherai:ideogram/ideogram-4.0',
|
||||
aliases: ['ideogram/ideogram-4.0', 'ideogram-4.0'],
|
||||
costs_currency: 'usd-cents',
|
||||
index_cost_key: '1MP',
|
||||
name: 'ideogram/ideogram-4.0',
|
||||
allowedQualityLevels: [''],
|
||||
pricing_unit: 'per-MP',
|
||||
costs: { '1MP': 6 },
|
||||
},
|
||||
{
|
||||
id: 'togetherai:openai/gpt-image-1.5',
|
||||
aliases: ['openai/gpt-image-1.5', 'gpt-image-1.5'],
|
||||
|
||||
@@ -49,8 +49,9 @@ import { XAIImageProvider } from './XAIImageProvider.js';
|
||||
|
||||
// ── OpenAI SDK mock ─────────────────────────────────────────────────
|
||||
|
||||
const { generateMock, openAICtor } = vi.hoisted(() => ({
|
||||
const { generateMock, postMock, openAICtor } = vi.hoisted(() => ({
|
||||
generateMock: vi.fn(),
|
||||
postMock: vi.fn(),
|
||||
openAICtor: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -61,6 +62,8 @@ vi.mock('openai', () => {
|
||||
) {
|
||||
openAICtor(opts);
|
||||
this.images = { generate: generateMock };
|
||||
// Low-level post() is how the provider reaches xAI's JSON edit endpoint.
|
||||
this.post = postMock;
|
||||
// Some sibling providers boot through the same SDK module.
|
||||
this.chat = { completions: { create: vi.fn() } };
|
||||
});
|
||||
@@ -71,7 +74,9 @@ vi.mock('openai', () => {
|
||||
|
||||
let server: PuterServer;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
let batchIncrementUsagesSpy: MockInstance<
|
||||
MeteringService['batchIncrementUsages']
|
||||
>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
@@ -86,9 +91,13 @@ const makeProvider = () =>
|
||||
|
||||
beforeEach(() => {
|
||||
generateMock.mockReset();
|
||||
postMock.mockReset();
|
||||
openAICtor.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
batchIncrementUsagesSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -121,15 +130,22 @@ describe('XAIImageProvider construction', () => {
|
||||
// ── Model catalog ───────────────────────────────────────────────────
|
||||
|
||||
describe('XAIImageProvider model catalog', () => {
|
||||
it('returns grok-2-image as the default', () => {
|
||||
it('returns grok-imagine-image as the default', () => {
|
||||
const provider = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe('grok-2-image');
|
||||
expect(provider.getDefaultModel()).toBe('grok-imagine-image');
|
||||
});
|
||||
|
||||
it('exposes the static XAI_IMAGE_GENERATION_MODELS list verbatim', () => {
|
||||
const provider = makeProvider();
|
||||
expect(provider.models()).toBe(XAI_IMAGE_GENERATION_MODELS);
|
||||
});
|
||||
|
||||
it('no longer exposes the deprecated grok-2-image model', () => {
|
||||
const provider = makeProvider();
|
||||
expect(provider.models().some((m) => m.id === 'grok-2-image')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── test_mode bypass ────────────────────────────────────────────────
|
||||
@@ -149,7 +165,7 @@ describe('XAIImageProvider.generate test_mode', () => {
|
||||
);
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,7 +204,7 @@ describe('XAIImageProvider.generate credit gate', () => {
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,7 +224,7 @@ describe('XAIImageProvider.generate model resolution', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(generateMock.mock.calls[0]![0].model).toBe('grok-2-image');
|
||||
expect(generateMock.mock.calls[0]![0].model).toBe('grok-imagine-image');
|
||||
});
|
||||
|
||||
it('resolves an alias to its canonical id', async () => {
|
||||
@@ -217,20 +233,20 @@ describe('XAIImageProvider.generate model resolution', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
// grok-image is an alias of grok-2-image.
|
||||
// grok-image is an alias of grok-imagine-image.
|
||||
model: 'grok-image',
|
||||
prompt: 'hi',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(generateMock.mock.calls[0]![0].model).toBe('grok-2-image');
|
||||
expect(generateMock.mock.calls[0]![0].model).toBe('grok-imagine-image');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Successful generation ───────────────────────────────────────────
|
||||
|
||||
describe('XAIImageProvider.generate success path', () => {
|
||||
it('returns the URL from response.data[0].url and meters one image', async () => {
|
||||
it('returns the URL from response.data[0].url and meters one image at the 1k output rate', async () => {
|
||||
const provider = makeProvider();
|
||||
generateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://x.ai/img/abc' }],
|
||||
@@ -238,22 +254,48 @@ describe('XAIImageProvider.generate success path', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'grok-2-image',
|
||||
model: 'grok-imagine-image',
|
||||
prompt: 'a small red dot',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBe('https://x.ai/img/abc');
|
||||
// No input images → generate endpoint, not the edit endpoint.
|
||||
expect(postMock).not.toHaveBeenCalled();
|
||||
|
||||
// One increment, at the model's per-image rate (7 cents → 7,000,000 ucents).
|
||||
const grok = XAI_IMAGE_GENERATION_MODELS.find(
|
||||
(m) => m.id === 'grok-2-image',
|
||||
(m) => m.id === 'grok-imagine-image',
|
||||
)!;
|
||||
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
|
||||
const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(usageType).toBe('xai:grok-2-image:output');
|
||||
expect(count).toBe(1);
|
||||
expect(cost).toBe(grok.costs.output * 1_000_000);
|
||||
expect(batchIncrementUsagesSpy).toHaveBeenCalledTimes(1);
|
||||
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
|
||||
expect(entries).toHaveLength(1);
|
||||
const out = (
|
||||
entries as Array<{ usageType: string; costOverride: number }>
|
||||
)[0];
|
||||
expect(out.usageType).toBe('xai:grok-imagine-image:output:1k');
|
||||
expect(out.costOverride).toBe(grok.costs['output:1k'] * 1_000_000);
|
||||
});
|
||||
|
||||
it('uses the 2k output rate when quality is "2k"', async () => {
|
||||
const provider = makeProvider();
|
||||
generateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://x.ai/img/2k' }],
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'grok-imagine-image-quality',
|
||||
prompt: 'hi',
|
||||
quality: '2k',
|
||||
}),
|
||||
);
|
||||
|
||||
const sent = generateMock.mock.calls[0]![0];
|
||||
expect(sent.resolution).toBe('2k');
|
||||
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
|
||||
expect(
|
||||
(entries as Array<{ usageType: string }>)[0].usageType,
|
||||
).toBe('xai:grok-imagine-image-quality:output:2k');
|
||||
});
|
||||
|
||||
it('falls back to a base64 data URL when response carries b64_json', async () => {
|
||||
@@ -264,7 +306,7 @@ describe('XAIImageProvider.generate success path', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'grok-2-image',
|
||||
model: 'grok-imagine-image',
|
||||
prompt: 'a small red dot',
|
||||
}),
|
||||
);
|
||||
@@ -279,13 +321,117 @@ describe('XAIImageProvider.generate success path', () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'grok-2-image',
|
||||
model: 'grok-imagine-image',
|
||||
prompt: 'a small red dot',
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/Failed to extract image URL/);
|
||||
|
||||
// Failure path must NOT meter usage.
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Image-to-image editing (input_images) ───────────────────────────
|
||||
|
||||
describe('XAIImageProvider.generate input_images (edit endpoint)', () => {
|
||||
const PNG = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
const editResponse = { data: [{ url: 'https://x.ai/img/edited' }] };
|
||||
|
||||
it('routes input_images to POST /v1/images/edits (not generate) with a single image object', async () => {
|
||||
const provider = makeProvider();
|
||||
postMock.mockResolvedValueOnce(editResponse);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'grok-imagine-image',
|
||||
prompt: 'add a hat',
|
||||
input_images: [PNG],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBe('https://x.ai/img/edited');
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
expect(postMock).toHaveBeenCalledTimes(1);
|
||||
const [path, opts] = postMock.mock.calls[0]!;
|
||||
expect(path).toBe('/images/edits');
|
||||
const body = (opts as { body: Record<string, unknown> }).body;
|
||||
expect(body.model).toBe('grok-imagine-image');
|
||||
// Single image → object, not an array.
|
||||
expect(body.image).toEqual({ type: 'image_url', url: PNG });
|
||||
});
|
||||
|
||||
it('sends an array of image objects for multi-image edits and caps at 3', async () => {
|
||||
const provider = makeProvider();
|
||||
postMock.mockResolvedValueOnce(editResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'grok-imagine-image',
|
||||
prompt: 'merge them',
|
||||
input_images: [PNG, PNG, PNG, PNG], // 4 → capped to 3
|
||||
}),
|
||||
);
|
||||
|
||||
const body = (
|
||||
postMock.mock.calls[0]![1] as { body: Record<string, unknown> }
|
||||
).body;
|
||||
expect(Array.isArray(body.image)).toBe(true);
|
||||
expect(body.image).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('meters output + media_input per input image on edits', async () => {
|
||||
const provider = makeProvider();
|
||||
postMock.mockResolvedValueOnce(editResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'grok-imagine-image',
|
||||
prompt: 'add a hat',
|
||||
input_images: [PNG, PNG],
|
||||
}),
|
||||
);
|
||||
|
||||
const grok = XAI_IMAGE_GENERATION_MODELS.find(
|
||||
(m) => m.id === 'grok-imagine-image',
|
||||
)!;
|
||||
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
|
||||
const types = (entries as Array<{ usageType: string }>).map(
|
||||
(e) => e.usageType,
|
||||
);
|
||||
expect(types).toEqual(
|
||||
expect.arrayContaining([
|
||||
'xai:grok-imagine-image:output:1k',
|
||||
'xai:grok-imagine-image:media_input',
|
||||
]),
|
||||
);
|
||||
const media = (
|
||||
entries as Array<{
|
||||
usageType: string;
|
||||
usageAmount: number;
|
||||
costOverride: number;
|
||||
}>
|
||||
).find((e) => e.usageType.endsWith(':media_input'))!;
|
||||
expect(media.usageAmount).toBe(2);
|
||||
expect(media.costOverride).toBe(grok.costs.media_input * 2 * 1_000_000);
|
||||
});
|
||||
|
||||
it('folds singular input_image into the edit path', async () => {
|
||||
const provider = makeProvider();
|
||||
postMock.mockResolvedValueOnce(editResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'grok-imagine-image',
|
||||
prompt: 'add a hat',
|
||||
input_image: PNG,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(postMock).toHaveBeenCalledTimes(1);
|
||||
const body = (
|
||||
postMock.mock.calls[0]![1] as { body: Record<string, unknown> }
|
||||
).body;
|
||||
expect(body.image).toEqual({ type: 'image_url', url: PNG });
|
||||
});
|
||||
});
|
||||
@@ -28,8 +28,13 @@ import type {
|
||||
import { XAI_IMAGE_GENERATION_MODELS } from './models.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
|
||||
const DEFAULT_MODEL = 'grok-2-image';
|
||||
const PRICE_KEY = 'output';
|
||||
const DEFAULT_MODEL = 'grok-imagine-image';
|
||||
// xAI's Grok Imagine edit endpoint accepts up to 3 source images per request.
|
||||
const MAX_INPUT_IMAGES = 3;
|
||||
|
||||
interface XaiImageResponse {
|
||||
data?: Array<{ url?: string; b64_json?: string }>;
|
||||
}
|
||||
|
||||
export class XAIImageProvider implements IImageProvider {
|
||||
#client: OpenAI;
|
||||
@@ -56,8 +61,9 @@ export class XAIImageProvider implements IImageProvider {
|
||||
}
|
||||
|
||||
async generate(params: IGenerateParams): Promise<string> {
|
||||
const { prompt, test_mode } = params;
|
||||
const { model } = params;
|
||||
const { prompt, test_mode, model, ratio, quality } = params;
|
||||
let { input_images } = params;
|
||||
const { input_image, input_image_mime_type } = params;
|
||||
|
||||
const selectedModel = this.#getModel(model);
|
||||
|
||||
@@ -71,15 +77,33 @@ export class XAIImageProvider implements IImageProvider {
|
||||
});
|
||||
}
|
||||
|
||||
// Backwards compat: fold singular `input_image` into `input_images`.
|
||||
if (input_image && (!input_images || input_images.length === 0)) {
|
||||
input_images = [input_image];
|
||||
}
|
||||
// xAI caps edits at 3 source images.
|
||||
if (input_images && input_images.length > MAX_INPUT_IMAGES) {
|
||||
input_images = input_images.slice(0, MAX_INPUT_IMAGES);
|
||||
}
|
||||
const inputImageCount = input_images?.length ?? 0;
|
||||
const hasInputImages = inputImageCount > 0;
|
||||
|
||||
// xAI uses a `resolution` tier ('1k'/'2k') rather than a pixel size.
|
||||
const resolution = this.#normalizeResolution(quality);
|
||||
const aspectRatio = this.#aspectRatio(ratio);
|
||||
|
||||
const actor = Context.get('actor');
|
||||
const userIdentifier =
|
||||
actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
|
||||
|
||||
const priceInCents = selectedModel.costs[PRICE_KEY];
|
||||
const costInMicroCents = priceInCents * 1_000_000;
|
||||
const outputPriceInCents = selectedModel.costs[`output:${resolution}`];
|
||||
const mediaInputPriceInCents = selectedModel.costs.media_input ?? 0;
|
||||
const estimatedCostInCents =
|
||||
outputPriceInCents +
|
||||
(hasInputImages ? mediaInputPriceInCents * inputImageCount : 0);
|
||||
const usageAllowed = await this.#meteringService.hasEnoughCredits(
|
||||
actor,
|
||||
costInMicroCents,
|
||||
estimatedCostInCents * 1_000_000,
|
||||
);
|
||||
|
||||
if (!usageAllowed) {
|
||||
@@ -90,15 +114,27 @@ export class XAIImageProvider implements IImageProvider {
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.#client.images.generate({
|
||||
model: selectedModel.id,
|
||||
prompt,
|
||||
user: userIdentifier,
|
||||
});
|
||||
const response = hasInputImages
|
||||
? await this.#edit(
|
||||
selectedModel.id,
|
||||
prompt,
|
||||
input_images!,
|
||||
input_image_mime_type,
|
||||
resolution,
|
||||
aspectRatio,
|
||||
)
|
||||
: ((await this.#client.images.generate({
|
||||
model: selectedModel.id,
|
||||
prompt,
|
||||
user: userIdentifier,
|
||||
// xAI-specific params not in the OpenAI type; passed through.
|
||||
...(aspectRatio ? { aspect_ratio: aspectRatio } : {}),
|
||||
resolution,
|
||||
} as Parameters<
|
||||
OpenAI['images']['generate']
|
||||
>[0])) as XaiImageResponse);
|
||||
|
||||
const first = response.data?.[0] as
|
||||
| { url?: string; b64_json?: string }
|
||||
| undefined;
|
||||
const first = response.data?.[0];
|
||||
const url =
|
||||
first?.url ||
|
||||
(first?.b64_json
|
||||
@@ -109,16 +145,75 @@ export class XAIImageProvider implements IImageProvider {
|
||||
throw new Error('Failed to extract image URL from xAI response');
|
||||
}
|
||||
|
||||
this.#meteringService.incrementUsage(
|
||||
actor,
|
||||
`xai:${selectedModel.id}:${PRICE_KEY}`,
|
||||
1,
|
||||
costInMicroCents,
|
||||
);
|
||||
const usageEntries = [
|
||||
{
|
||||
usageType: `xai:${selectedModel.id}:output:${resolution}`,
|
||||
usageAmount: 1,
|
||||
costOverride: outputPriceInCents * 1_000_000,
|
||||
},
|
||||
];
|
||||
if (hasInputImages && mediaInputPriceInCents > 0) {
|
||||
usageEntries.push({
|
||||
usageType: `xai:${selectedModel.id}:media_input`,
|
||||
usageAmount: inputImageCount,
|
||||
costOverride:
|
||||
mediaInputPriceInCents * inputImageCount * 1_000_000,
|
||||
});
|
||||
}
|
||||
this.#meteringService.batchIncrementUsages(actor, usageEntries);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
// Edits go to POST /v1/images/edits as application/json (the OpenAI SDK's
|
||||
// images.edit() can't be used — it sends multipart/form-data, which xAI
|
||||
// rejects). We reuse the SDK client's auth + baseURL via its low-level
|
||||
// post(). Input images are passed as `{ type: 'image_url', url }` objects;
|
||||
// a single object for one image, an array for multiple.
|
||||
async #edit(
|
||||
modelId: string,
|
||||
prompt: string,
|
||||
inputImages: string[],
|
||||
mimeHint: string | undefined,
|
||||
resolution: string,
|
||||
aspectRatio: string | undefined,
|
||||
): Promise<XaiImageResponse> {
|
||||
const refs = inputImages.map((img) => this.#toImageRef(img, mimeHint));
|
||||
const body: Record<string, unknown> = {
|
||||
model: modelId,
|
||||
prompt,
|
||||
image: refs.length === 1 ? refs[0] : refs,
|
||||
resolution,
|
||||
};
|
||||
if (aspectRatio) body.aspect_ratio = aspectRatio;
|
||||
return (await this.#client.post('/images/edits', {
|
||||
body,
|
||||
})) as XaiImageResponse;
|
||||
}
|
||||
|
||||
// xAI accepts a public URL or a base64 data URI for input images.
|
||||
#toImageRef(img: string, mimeHint?: string) {
|
||||
const url =
|
||||
img.startsWith('http://') ||
|
||||
img.startsWith('https://') ||
|
||||
img.startsWith('data:')
|
||||
? img
|
||||
: `data:${mimeHint ?? 'image/png'};base64,${img}`;
|
||||
return { type: 'image_url', url };
|
||||
}
|
||||
|
||||
#normalizeResolution(quality?: string): '1k' | '2k' {
|
||||
return (quality ?? '').toLowerCase() === '2k' ? '2k' : '1k';
|
||||
}
|
||||
|
||||
#aspectRatio(ratio?: { w: number; h: number }): string | undefined {
|
||||
if (!ratio || !ratio.w || !ratio.h) return undefined;
|
||||
const gcd = (a: number, b: number): number =>
|
||||
b === 0 ? a : gcd(b, a % b);
|
||||
const d = gcd(ratio.w, ratio.h) || 1;
|
||||
return `${ratio.w / d}:${ratio.h / d}`;
|
||||
}
|
||||
|
||||
#getModel(model?: string) {
|
||||
const models = this.models();
|
||||
const found = models.find(
|
||||
|
||||
@@ -19,17 +19,43 @@
|
||||
|
||||
import type { IImageModel } from '../../types.js';
|
||||
|
||||
// Costs are in usd-cents (1 = $0.01). xAI's "Grok Imagine" image API bills a
|
||||
// per-image output rate by resolution tier (1k/2k) plus, for edits, a
|
||||
// per-input-image "media input" rate. Rates per the xAI Imagine pricing table:
|
||||
// https://docs.x.ai/developers/pricing
|
||||
// grok-imagine-image media $0.002 | 1k $0.02 | 2k $0.02
|
||||
// grok-imagine-image-quality media $0.01 | 1k $0.05 | 2k $0.07
|
||||
export const XAI_IMAGE_GENERATION_MODELS: IImageModel[] = [
|
||||
{
|
||||
puterId: 'x-ai:x-ai/grok-2-image',
|
||||
id: 'grok-2-image',
|
||||
aliases: ['grok-image', 'x-ai/grok-image', 'x-ai/grok-2-image'],
|
||||
name: 'Grok 2 Image',
|
||||
puterId: 'x-ai:x-ai/grok-imagine-image',
|
||||
id: 'grok-imagine-image',
|
||||
aliases: ['grok-image', 'x-ai/grok-image', 'x-ai/grok-imagine-image'],
|
||||
name: 'Grok Imagine Image',
|
||||
version: '1.0',
|
||||
costs_currency: 'usd-cents',
|
||||
index_cost_key: 'output',
|
||||
pricing_unit: 'per-image',
|
||||
index_cost_key: 'output:1k',
|
||||
costs: {
|
||||
output: 7, // $0.07 per image
|
||||
'output:1k': 2, // $0.02 per image
|
||||
'output:2k': 2, // $0.02 per image
|
||||
media_input: 0.2, // $0.002 per input image (edits)
|
||||
},
|
||||
allowedQualityLevels: ['1k', '2k'],
|
||||
},
|
||||
{
|
||||
puterId: 'x-ai:x-ai/grok-imagine-image-quality',
|
||||
id: 'grok-imagine-image-quality',
|
||||
aliases: ['x-ai/grok-imagine-image-quality'],
|
||||
name: 'Grok Imagine Image (Quality)',
|
||||
version: '1.0',
|
||||
costs_currency: 'usd-cents',
|
||||
pricing_unit: 'per-image',
|
||||
index_cost_key: 'output:1k',
|
||||
costs: {
|
||||
'output:1k': 5, // $0.05 per image
|
||||
'output:2k': 7, // $0.07 per image
|
||||
media_input: 1, // $0.01 per input image (edits)
|
||||
},
|
||||
allowedQualityLevels: ['1k', '2k'],
|
||||
},
|
||||
];
|
||||
@@ -1117,7 +1117,15 @@ export class AppDriver extends PuterDriver {
|
||||
const subdomain = this.#extractPuterHostedSubdomain(indexUrl);
|
||||
if (!subdomain) return;
|
||||
|
||||
const row = await this.stores.subdomain.getBySubdomain(subdomain);
|
||||
let row = await this.stores.subdomain.getBySubdomain(subdomain);
|
||||
if (!row) {
|
||||
// Deploys create the subdomain and immediately point the app
|
||||
// at it, so a replica or peer-cache miss here would wrongly
|
||||
// refuse the owner. Confirm against the primary before failing.
|
||||
row = await this.stores.subdomain.getBySubdomain(subdomain, {
|
||||
primary: true,
|
||||
});
|
||||
}
|
||||
if (!row || row.user_id !== user.id) {
|
||||
throw new HttpError(400, 'Subdomain not owned by user', {
|
||||
legacyCode: 'subdomain_not_owned',
|
||||
|
||||
@@ -1081,3 +1081,98 @@ describe('AppDriver alias-group index_url merge', () => {
|
||||
expect(first.uid).not.toBe(second.uid);
|
||||
});
|
||||
});
|
||||
|
||||
// ── hosted-subdomain ownership check ────────────────────────────────
|
||||
//
|
||||
// `#ensurePuterSiteSubdomainIsOwned` gates puter-hosted index_urls on a
|
||||
// subdomain row the caller owns. Deploy flows create that row and point
|
||||
// the app at it in back-to-back requests, so the check must tolerate a
|
||||
// replica/cache miss by confirming against the primary before refusing.
|
||||
|
||||
describe('AppDriver hosted-subdomain ownership check', () => {
|
||||
const hostedUrl = (sub: string) => `https://${sub}.site.puter.localhost/`;
|
||||
|
||||
it('accepts a hosted index_url when the subdomain row has not reached the replica yet', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const sub = uniqueName('deploy');
|
||||
await server.stores.subdomain.create({ userId, subdomain: sub });
|
||||
|
||||
// Simulate a peer node with a lagging replica: no cache entry for
|
||||
// the row, and replica reads (`read`) that don't see it yet while
|
||||
// primary reads (`pread`) do. Sqlite's `pread` delegates to
|
||||
// `this.read`, so pin it to the original before stubbing `read`.
|
||||
await server.clients.redis.del(`subdomains:name:${sub}`);
|
||||
const db = server.clients.db as unknown as {
|
||||
read: (q: string, p?: unknown[]) => Promise<unknown[]>;
|
||||
pread: (q: string, p?: unknown[]) => Promise<unknown[]>;
|
||||
};
|
||||
const originalRead = db.read.bind(server.clients.db);
|
||||
const hadOwnPread = Object.prototype.hasOwnProperty.call(db, 'pread');
|
||||
db.pread = async (query: string, params?: unknown[]) =>
|
||||
originalRead(query, params);
|
||||
db.read = async (query: string, params?: unknown[]) =>
|
||||
query.includes('FROM `subdomains`') && (params ?? []).includes(sub)
|
||||
? []
|
||||
: originalRead(query, params);
|
||||
|
||||
try {
|
||||
const result = await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('app'),
|
||||
title: 'Deployed App',
|
||||
index_url: hostedUrl(sub),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result.uid).toEqual(expect.any(String));
|
||||
|
||||
// The primary hit must also heal the stale cache: a normal
|
||||
// lookup now resolves from cache even though the replica
|
||||
// still misses.
|
||||
const healed =
|
||||
await server.stores.subdomain.getBySubdomain(sub);
|
||||
expect(healed?.subdomain).toBe(sub);
|
||||
} finally {
|
||||
delete (db as { read?: unknown }).read;
|
||||
if (!hadOwnPread) delete (db as { pread?: unknown }).pread;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a hosted index_url whose subdomain does not exist anywhere', async () => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('app'),
|
||||
title: 'x',
|
||||
index_url: hostedUrl(uniqueName('ghost')),
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it("rejects a hosted index_url pointing at another user's subdomain", async () => {
|
||||
const owner = await makeUser();
|
||||
const intruder = await makeUser();
|
||||
const sub = uniqueName('theirs');
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.userId,
|
||||
subdomain: sub,
|
||||
});
|
||||
|
||||
await expect(
|
||||
withActor(intruder.actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('app'),
|
||||
title: 'x',
|
||||
index_url: hostedUrl(sub),
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
@@ -244,9 +244,11 @@ export class SubdomainDriver extends PuterDriver {
|
||||
if (object.domain !== undefined)
|
||||
patch.domain = object.domain != null ? String(object.domain) : null;
|
||||
|
||||
const updated = await this.stores.subdomain.update(row.uuid, patch, {
|
||||
userId: row.user_id,
|
||||
});
|
||||
const updated = await this.stores.subdomain.update(
|
||||
String(row.uuid),
|
||||
patch,
|
||||
{ userId: row.user_id as number },
|
||||
);
|
||||
const [shaped] = await this.#hydrateRows(
|
||||
updated ? [updated as Record<string, unknown>] : [],
|
||||
);
|
||||
@@ -351,8 +353,8 @@ export class SubdomainDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
await this.#checkWriteAccess(row, actor);
|
||||
await this.stores.subdomain.deleteByUuid(row.uuid, {
|
||||
userId: row.user_id,
|
||||
await this.stores.subdomain.deleteByUuid(String(row.uuid), {
|
||||
userId: row.user_id as number,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -292,6 +292,22 @@ describe('loadFileInput FS path', () => {
|
||||
expect(result.fsEntry?.path).toBe(path);
|
||||
});
|
||||
|
||||
it('returns empty bytes for an empty file with no backing S3 object', async () => {
|
||||
// Files created via touch have size 0 and a null bucket — no S3
|
||||
// object exists. loadFileInput must return empty content rather than
|
||||
// throwing NoSuchKey from getObjectStream.
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const path = `/${username}/Documents/empty.txt`;
|
||||
const entry = await withActor(actor, () =>
|
||||
server.services.fs.touch(userId, { path }),
|
||||
);
|
||||
|
||||
const result = await callLoadFileInput(actor, path);
|
||||
expect(result.buffer.byteLength).toBe(0);
|
||||
expect(result.fsEntry?.uuid).toBe(entry.uuid);
|
||||
});
|
||||
|
||||
it('also accepts a `{ uuid }` object reference', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { FSService } from '../../services/fs/FSService.js';
|
||||
import { expandTildePath, resolveNode } from '../../services/fs/resolveNode.js';
|
||||
import { hasNoBackingS3Object } from '../../stores/fs/FSEntry.js';
|
||||
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
|
||||
import type { S3ObjectStore } from '../../stores/fs/S3ObjectStore.js';
|
||||
import { mimeFromName } from '../../util/fileSigning.js';
|
||||
@@ -175,6 +176,23 @@ export async function loadFileInput(
|
||||
// `path`/`uid`/`uuid` (e.g. AI chat `puter_path` content parts) could
|
||||
// exfiltrate any user's file. Must run before the S3 read below.
|
||||
await fsService.checkFSAccess(entry, actor, 'read');
|
||||
// Empty files (created via `touch`) have no backing S3 object —
|
||||
// getObjectStream would throw NoSuchKey, so return empty content.
|
||||
if (hasNoBackingS3Object(entry)) {
|
||||
return {
|
||||
buffer: Buffer.alloc(0),
|
||||
filename: entry.name,
|
||||
mimeType: mimeFromName(entry.name) ?? 'application/octet-stream',
|
||||
fsEntry: {
|
||||
uuid: entry.uuid,
|
||||
path: entry.path,
|
||||
bucket: entry.bucket,
|
||||
bucketRegion: entry.bucketRegion,
|
||||
size: entry.size,
|
||||
sqlId: entry.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
const objectKey = entry.uuid;
|
||||
const { body, contentType, contentLength } =
|
||||
await stores.s3Object.getObjectStream(
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Context } from '../../core/context.js';
|
||||
import { HttpError, type LegacyErrorCodes } from '../../core/http/HttpError.js';
|
||||
import { assertVerifiedEmail } from '../../core/http/verifiedEmail.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import type { SubdomainRow } from '../../stores/subdomain/SubdomainStore.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { loadFileInput } from '../util/fileInput.js';
|
||||
|
||||
@@ -33,6 +34,7 @@ const WORKER_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
const MAX_WORKERS_PER_USER = 100;
|
||||
const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
const WORKER_SUBDOMAIN_PREFIX = 'workers.puter.';
|
||||
let USE_LOCAL_WORKERD = false;
|
||||
|
||||
// -- Preamble --------------------------------------------------------
|
||||
//
|
||||
@@ -66,6 +68,16 @@ try {
|
||||
preambleError = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The puter.js/router preamble prepended to every worker's source before
|
||||
* deploy. Exposed so the local-workerd path (`LocalWorkerService`) can build
|
||||
* the same `preamble + sourceCode` script when it lazily re-deploys a worker
|
||||
* into Miniflare after a server restart.
|
||||
*/
|
||||
export function getWorkerPreamble(): string {
|
||||
return preamble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver exposing the `workers` interface — Cloudflare Workers
|
||||
* deployment, lifecycle, and file-path queries.
|
||||
@@ -100,6 +112,8 @@ export class WorkerDriver extends PuterDriver {
|
||||
'[workers] preamble not build but workers configured to be enabled. Halting start',
|
||||
);
|
||||
}
|
||||
} else if (cfg.localServer) {
|
||||
USE_LOCAL_WORKERD = true;
|
||||
}
|
||||
this.#subscribeHotReload();
|
||||
}
|
||||
@@ -165,7 +179,8 @@ export class WorkerDriver extends PuterDriver {
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
appOwnerId = actor.app?.id;
|
||||
const ownerApp = await this.stores.app.getByUid(appId);
|
||||
appOwnerId = ownerApp?.id ?? actor.app?.id;
|
||||
authorization = await this.services.auth.createWorkerAppToken(
|
||||
actor,
|
||||
appId,
|
||||
@@ -248,6 +263,13 @@ export class WorkerDriver extends PuterDriver {
|
||||
});
|
||||
}
|
||||
|
||||
if (appId) {
|
||||
await this.services.fs.mkdir(actor.user.id!, {
|
||||
path: `/${actor.user.username}/AppData/${appId}`,
|
||||
createMissingParents: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Deploy to Cloudflare
|
||||
const cfResult = await this.#cfDeploy(
|
||||
workerName,
|
||||
@@ -292,7 +314,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
const actor = this.#requireActor();
|
||||
const workerName = args.workerName as string | undefined;
|
||||
|
||||
let rows: Array<Record<string, unknown>>;
|
||||
let rows: SubdomainRow[];
|
||||
if (typeof workerName === 'string' && workerName.length > 0) {
|
||||
const sub = await this.stores.subdomain.getBySubdomain(
|
||||
`${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
|
||||
@@ -357,6 +379,13 @@ export class WorkerDriver extends PuterDriver {
|
||||
authorization: string,
|
||||
code: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (USE_LOCAL_WORKERD) {
|
||||
return this.services.localworkerservice.cfDeployLocal(
|
||||
workerName,
|
||||
authorization,
|
||||
code,
|
||||
);
|
||||
}
|
||||
const cfg = this.#workerConfig();
|
||||
const metadata = JSON.stringify({
|
||||
body_part: 'swCode',
|
||||
@@ -422,6 +451,9 @@ export class WorkerDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async #cfDelete(workerName: string): Promise<Record<string, unknown>> {
|
||||
if (USE_LOCAL_WORKERD) {
|
||||
return this.services.localworkerservice.cfDeleteLocal(workerName);
|
||||
}
|
||||
const cfg = this.#workerConfig();
|
||||
const res = await fetch(`${this.#cfBaseUrl}/scripts/${workerName}/`, {
|
||||
method: 'DELETE',
|
||||
@@ -447,7 +479,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
|
||||
#requireCfConfig(): void {
|
||||
const cfg = this.#workerConfig();
|
||||
if (!cfg.XAUTHKEY || !cfg.ACCOUNTID) {
|
||||
if ((!cfg.XAUTHKEY || !cfg.ACCOUNTID) && !cfg.localServer) {
|
||||
throw new HttpError(503, 'Cloudflare Workers not configured', {
|
||||
legacyCode: 'response_timeout',
|
||||
});
|
||||
@@ -464,7 +496,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
#checkWorkerWriteAccess(
|
||||
row: Record<string, unknown>,
|
||||
row: SubdomainRow,
|
||||
actor: Actor & { user: { id: number } },
|
||||
errorStatus: number,
|
||||
errorMessage: string,
|
||||
@@ -519,7 +551,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
// while worker subdomains are keyed to the numeric fsentries.id.
|
||||
|
||||
#subscribeHotReload(): void {
|
||||
if (!this.#cfBaseUrl) return; // CF not configured — skip
|
||||
if (!this.#cfBaseUrl && !USE_LOCAL_WORKERD) return;
|
||||
|
||||
this.clients.event.on(
|
||||
'fs.write.file',
|
||||
@@ -707,14 +739,12 @@ export class WorkerDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
async #listWorkerRowsForEntry(
|
||||
entry: FSEntry,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
async #listWorkerRowsForEntry(entry: FSEntry): Promise<SubdomainRow[]> {
|
||||
const workerSubs = await this.stores.subdomain.listByUserIdAndPrefix(
|
||||
entry.userId,
|
||||
WORKER_SUBDOMAIN_PREFIX,
|
||||
);
|
||||
return workerSubs.filter((r: Record<string, unknown>) => {
|
||||
return workerSubs.filter((r) => {
|
||||
return (
|
||||
String(r.root_dir_id) === String(entry.id) ||
|
||||
String(r.root_dir_id) === String(entry.uuid) ||
|
||||
@@ -726,17 +756,17 @@ export class WorkerDriver extends PuterDriver {
|
||||
async #listWorkerRowsUnderPath(
|
||||
userId: number,
|
||||
parentPath: string,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
): Promise<SubdomainRow[]> {
|
||||
const workerSubs = await this.stores.subdomain.listByUserIdAndPrefix(
|
||||
userId,
|
||||
WORKER_SUBDOMAIN_PREFIX,
|
||||
);
|
||||
const rootDirIds = workerSubs
|
||||
.map((r: Record<string, unknown>) => r.root_dir_id)
|
||||
.map((r) => r.root_dir_id)
|
||||
.filter((id): id is number => typeof id === 'number');
|
||||
const entriesById =
|
||||
await this.stores.fsEntry.getEntriesByIds(rootDirIds);
|
||||
return workerSubs.filter((row: Record<string, unknown>) => {
|
||||
return workerSubs.filter((row) => {
|
||||
const rootDirId = row.root_dir_id;
|
||||
if (typeof rootDirId !== 'number') return false;
|
||||
const entry = entriesById.get(rootDirId);
|
||||
@@ -753,7 +783,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async #deleteWorkerForSourceRow(
|
||||
row: Record<string, unknown>,
|
||||
row: SubdomainRow,
|
||||
userId: number,
|
||||
): Promise<void> {
|
||||
const workerFullName = String(row.subdomain ?? '');
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isSpanContextValid, trace } from '@opentelemetry/api';
|
||||
import { puterClients } from './clients';
|
||||
import { puterControllers } from './controllers';
|
||||
import { puterDrivers } from './drivers';
|
||||
@@ -26,6 +27,7 @@ import { PuterServer } from './server';
|
||||
import { puterServices } from './services';
|
||||
import { puterStores } from './stores';
|
||||
import type { IConfig } from './types';
|
||||
import { installJsonConsole } from './util/jsonConsole.js';
|
||||
|
||||
// Config resolution order:
|
||||
// 1. `process.env.PUTER_CONFIG_PATH` — absolute path to a config file. Used
|
||||
@@ -167,6 +169,23 @@ const loadConfig = (): IConfig => {
|
||||
// if called directly, start the server
|
||||
if (require.main === module) {
|
||||
const config = loadConfig();
|
||||
|
||||
// Structured logging: when `log_format: "json"`, replace the global console
|
||||
// so each call emits one JSON line (level, timestamp, msg, and the active
|
||||
// trace id) — one event per call, so a line-oriented log collector can't
|
||||
// split stack traces across events. Installed here rather than in the OTel
|
||||
// preload so it applies even when telemetry is disabled; the trace id is
|
||||
// simply absent when no span is active.
|
||||
if (config.log_format === 'json') {
|
||||
installJsonConsole({
|
||||
getTraceContext: () => {
|
||||
const ctx = trace.getActiveSpan()?.spanContext();
|
||||
if (!ctx || !isSpanContextValid(ctx)) return undefined;
|
||||
return { traceId: ctx.traceId, spanId: ctx.spanId };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const server = new PuterServer(
|
||||
config,
|
||||
puterClients,
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.6.1",
|
||||
"@types/validator": "^13.15.10",
|
||||
"chai": "^4.3.7",
|
||||
|
||||
+73
-14
@@ -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';
|
||||
@@ -60,6 +62,7 @@ import {
|
||||
createUserSubdomainRedirect,
|
||||
createNativeAppStatic,
|
||||
} from './core/http/middleware/hostRedirects';
|
||||
import { createLocalWorkerProxyMiddleware } from './core/http/middleware/localWorkerProxy';
|
||||
import { createPuterSiteMiddleware } from './core/http/middleware/puterSite';
|
||||
import { PuterRouter } from './core/http/PuterRouter';
|
||||
import { createRouteLifecycleMiddleware } from './core/http/routeLifecycle';
|
||||
@@ -452,6 +455,18 @@ export class PuterServer {
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
// -- Local Worker proxy (*.workers.puter.localhost) ----------
|
||||
// Dev-only Miniflare dispatch, gated on `config.workers.localServer`.
|
||||
// Mounted BEFORE body parsing so the Worker receives the raw request
|
||||
// stream; no-op in production (real Cloudflare via WorkerDriver).
|
||||
this.#app.use(
|
||||
createLocalWorkerProxyMiddleware(this.#config, {
|
||||
clients: this.clients,
|
||||
stores: this.stores,
|
||||
services: this.services,
|
||||
}),
|
||||
);
|
||||
|
||||
// -- Body parsing (JSON + text-as-json shim) -----------------
|
||||
const captureRawBody: NonNullable<
|
||||
Parameters<typeof express.json>[0]
|
||||
@@ -481,7 +496,6 @@ export class PuterServer {
|
||||
createAuthProbe({
|
||||
authService,
|
||||
cookieName: this.#config.cookie_name,
|
||||
kvStore: this.stores.kv,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -729,11 +743,13 @@ export class PuterServer {
|
||||
if (status < 500) return;
|
||||
if (SKIP_ALERT_PREFIXES.test(legacyCode)) return;
|
||||
}
|
||||
const signature = isHttp
|
||||
? err.legacyCode || err.code || err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
const signature = !isHttp
|
||||
? err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
: status >= 500
|
||||
? `${err.legacyCode || err.code || 'http'}:${err.message}`
|
||||
: err.legacyCode || err.code || err.message;
|
||||
const routePath =
|
||||
(req as unknown as { route?: { path?: string } }).route
|
||||
?.path ?? req.path;
|
||||
@@ -862,10 +878,12 @@ export class PuterServer {
|
||||
// carry, so it works for either actor shape.
|
||||
//
|
||||
// `adminOnly` also does NOT imply `requireUserActor`: admin endpoints
|
||||
// should be callable from scripts/automation using an admin's access
|
||||
// token, not only from browser sessions. `adminOnlyGate` gates on
|
||||
// `actor.user.username`, which is populated for access-token and
|
||||
// app-under-user actors alike.
|
||||
// stay callable from scripts/automation using an admin's full-access
|
||||
// token, not only from browser sessions — both are root tokens.
|
||||
// Beyond the username check, `adminOnlyGate` requires a root token
|
||||
// (rejecting an admin acting through a third-party app) unless the
|
||||
// route is also appId-gated, in which case `allowedAppIdsGate` governs
|
||||
// which apps may pass.
|
||||
if (opts.requireUserActor) {
|
||||
mwChain.push(
|
||||
requireUserActorGate({
|
||||
@@ -876,7 +894,11 @@ export class PuterServer {
|
||||
|
||||
if (opts.adminOnly) {
|
||||
const extras = Array.isArray(opts.adminOnly) ? opts.adminOnly : [];
|
||||
mwChain.push(adminOnlyGate(extras));
|
||||
mwChain.push(
|
||||
adminOnlyGate(extras, {
|
||||
appGated: Boolean(opts.allowedAppIds),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.allowedAppIds) {
|
||||
@@ -1144,9 +1166,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=<gui-origin>`)
|
||||
// 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);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AppOriginBlocklistService } from './AppOriginBlocklistService.js';
|
||||
|
||||
type Row = {
|
||||
domain: string;
|
||||
include_subdomains: number;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
const makeService = (
|
||||
rows: Row[],
|
||||
read?: () => Promise<unknown>,
|
||||
): { service: AppOriginBlocklistService; reads: { count: number } } => {
|
||||
const reads = { count: 0 };
|
||||
const db = {
|
||||
read:
|
||||
read ??
|
||||
(async () => {
|
||||
reads.count++;
|
||||
return rows;
|
||||
}),
|
||||
};
|
||||
const service = new AppOriginBlocklistService(
|
||||
{} as never,
|
||||
{ db } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, reads };
|
||||
};
|
||||
|
||||
describe('AppOriginBlocklistService', () => {
|
||||
describe('isHostBlocked — exact entries', () => {
|
||||
it('matches the exact host only', async () => {
|
||||
const { service } = makeService([
|
||||
{ domain: 'some.evil.com', include_subdomains: 0 },
|
||||
]);
|
||||
expect(await service.isHostBlocked('some.evil.com')).toEqual({
|
||||
blocked: true,
|
||||
reason: undefined,
|
||||
});
|
||||
expect((await service.isHostBlocked('evil.com')).blocked).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
(await service.isHostBlocked('x.some.evil.com')).blocked,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHostBlocked — include_subdomains entries', () => {
|
||||
it('matches the apex and any subdomain, but not lookalikes', async () => {
|
||||
const { service } = makeService([
|
||||
{ domain: 'evil.com', include_subdomains: 1, reason: 'abuse' },
|
||||
]);
|
||||
expect(await service.isHostBlocked('evil.com')).toEqual({
|
||||
blocked: true,
|
||||
reason: 'abuse',
|
||||
});
|
||||
expect((await service.isHostBlocked('a.evil.com')).blocked).toBe(
|
||||
true,
|
||||
);
|
||||
expect((await service.isHostBlocked('a.b.evil.com')).blocked).toBe(
|
||||
true,
|
||||
);
|
||||
// Suffix-but-not-subdomain must NOT match.
|
||||
expect((await service.isHostBlocked('notevil.com')).blocked).toBe(
|
||||
false,
|
||||
);
|
||||
expect((await service.isHostBlocked('evil.com.org')).blocked).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalization', () => {
|
||||
it('lowercases, strips port, and ignores empty input', async () => {
|
||||
const { service } = makeService([
|
||||
{ domain: 'evil.com', include_subdomains: 1 },
|
||||
]);
|
||||
expect((await service.isHostBlocked('A.EVIL.COM')).blocked).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
(await service.isHostBlocked('a.evil.com:8080')).blocked,
|
||||
).toBe(true);
|
||||
expect((await service.isHostBlocked('')).blocked).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes stored entries too (uppercase/leading dot)', async () => {
|
||||
const { service } = makeService([
|
||||
{ domain: '.Evil.COM', include_subdomains: 1 },
|
||||
]);
|
||||
expect((await service.isHostBlocked('a.evil.com')).blocked).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOriginBlocked', () => {
|
||||
it('extracts the host from a full URL', async () => {
|
||||
const { service } = makeService([
|
||||
{ domain: 'evil.com', include_subdomains: 1 },
|
||||
]);
|
||||
expect(
|
||||
(await service.isOriginBlocked('https://app.evil.com/path?q=1'))
|
||||
.blocked,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(await service.isOriginBlocked('https://good.com/')).blocked,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a scheme-less origin', async () => {
|
||||
const { service } = makeService([
|
||||
{ domain: 'evil.com', include_subdomains: 0 },
|
||||
]);
|
||||
expect((await service.isOriginBlocked('evil.com')).blocked).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('caching', () => {
|
||||
it('reuses the cached snapshot within the TTL', async () => {
|
||||
const { service, reads } = makeService([
|
||||
{ domain: 'evil.com', include_subdomains: 0 },
|
||||
]);
|
||||
await service.isHostBlocked('evil.com');
|
||||
await service.isHostBlocked('evil.com');
|
||||
expect(reads.count).toBe(1);
|
||||
});
|
||||
|
||||
it('reloads after invalidate()', async () => {
|
||||
const { service, reads } = makeService([
|
||||
{ domain: 'evil.com', include_subdomains: 0 },
|
||||
]);
|
||||
await service.isHostBlocked('evil.com');
|
||||
service.invalidate();
|
||||
await service.isHostBlocked('evil.com');
|
||||
expect(reads.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resilience', () => {
|
||||
it('fails open (not blocked) when the DB read throws', async () => {
|
||||
const { service } = makeService([], async () => {
|
||||
throw new Error('no such table: blocked_app_origins');
|
||||
});
|
||||
expect((await service.isHostBlocked('evil.com')).blocked).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { PuterService } from '../types.js';
|
||||
|
||||
export interface BlockMatch {
|
||||
blocked: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface BlocklistEntry {
|
||||
domain: string;
|
||||
includeSubdomains: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
const NOT_BLOCKED: BlockMatch = { blocked: false };
|
||||
|
||||
/**
|
||||
* In-memory, TTL-cached view of the `blocked_app_origins` table.
|
||||
*
|
||||
* Admins manage the table (the admin extension writes it directly, mirroring
|
||||
* how it writes the `user` table for suspend). This service answers the hot
|
||||
* "is this app/origin blocked?" question from a cached snapshot so the
|
||||
* per-request app-token validation path never hits the DB.
|
||||
*
|
||||
* Consistency: the cache refreshes lazily after {@link CACHE_TTL_MS}. Because
|
||||
* each worker process holds its own cache, a freshly-added block can take up
|
||||
* to one TTL to take effect across the fleet — acceptable for an
|
||||
* admin-initiated block.
|
||||
*/
|
||||
export class AppOriginBlocklistService extends PuterService {
|
||||
private static readonly CACHE_TTL_MS = 30_000;
|
||||
|
||||
#entries: BlocklistEntry[] = [];
|
||||
#loadedAt = 0;
|
||||
#inflight: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Decide whether a bare host (already without scheme/path) is blocked.
|
||||
* Exact entries match the host verbatim; `include_subdomains` entries
|
||||
* also match any subdomain of `domain`.
|
||||
*/
|
||||
async isHostBlocked(host: string): Promise<BlockMatch> {
|
||||
const normalized = normalizeHost(host);
|
||||
if (!normalized) return NOT_BLOCKED;
|
||||
|
||||
await this.#ensureFresh();
|
||||
for (const entry of this.#entries) {
|
||||
const matches = entry.includeSubdomains
|
||||
? normalized === entry.domain ||
|
||||
normalized.endsWith(`.${entry.domain}`)
|
||||
: normalized === entry.domain;
|
||||
if (matches) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: entry.reason ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
return NOT_BLOCKED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an origin/URL is blocked by extracting its host. Accepts
|
||||
* full URLs (`https://app.example.com/path`) and bare hosts alike.
|
||||
*/
|
||||
async isOriginBlocked(origin: string): Promise<BlockMatch> {
|
||||
return this.isHostBlocked(hostFromOrigin(origin));
|
||||
}
|
||||
|
||||
/** Drop the cached snapshot so the next query reloads from the DB. */
|
||||
invalidate(): void {
|
||||
this.#loadedAt = 0;
|
||||
}
|
||||
|
||||
async #ensureFresh(): Promise<void> {
|
||||
const age = Date.now() - this.#loadedAt;
|
||||
if (
|
||||
this.#loadedAt !== 0 &&
|
||||
age < AppOriginBlocklistService.CACHE_TTL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Single-flight: concurrent callers share one reload.
|
||||
if (!this.#inflight) {
|
||||
this.#inflight = this.#reload().finally(() => {
|
||||
this.#inflight = null;
|
||||
});
|
||||
}
|
||||
await this.#inflight;
|
||||
}
|
||||
|
||||
async #reload(): Promise<void> {
|
||||
try {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT `domain`, `include_subdomains`, `reason` FROM `blocked_app_origins`',
|
||||
)) as Array<Record<string, unknown>>;
|
||||
this.#entries = rows
|
||||
.map((row) => {
|
||||
const domain = normalizeHost(String(row.domain ?? ''));
|
||||
if (!domain) return null;
|
||||
return {
|
||||
domain,
|
||||
includeSubdomains: Boolean(
|
||||
Number(row.include_subdomains ?? 0),
|
||||
),
|
||||
reason: row.reason == null ? null : String(row.reason),
|
||||
} satisfies BlocklistEntry;
|
||||
})
|
||||
.filter((e): e is BlocklistEntry => e !== null);
|
||||
this.#loadedAt = Date.now();
|
||||
} catch (e) {
|
||||
// Never let a transient DB error turn into a request-blocking
|
||||
// throw on the auth hot path. Keep serving the previous snapshot;
|
||||
// a missing table (fresh dev DB pre-migration) yields an empty
|
||||
// blocklist, which is the safe-open default.
|
||||
console.warn(
|
||||
'[app-origin-blocklist] reload failed:',
|
||||
(e as Error)?.message ?? e,
|
||||
);
|
||||
if (this.#loadedAt === 0) {
|
||||
this.#entries = [];
|
||||
this.#loadedAt = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Lowercase, trim, drop a leading dot and any port. Returns '' when unusable. */
|
||||
const normalizeHost = (host: string): string => {
|
||||
let h = (host ?? '').trim().toLowerCase();
|
||||
if (!h) return '';
|
||||
h = h.replace(/^\./, '');
|
||||
// Strip a trailing :port (IPv6 literals are not app origins, so the
|
||||
// simple split is safe here).
|
||||
const colon = h.indexOf(':');
|
||||
if (colon !== -1) h = h.slice(0, colon);
|
||||
return h;
|
||||
};
|
||||
|
||||
/** Extract the host from a full URL, falling back to treating input as a host. */
|
||||
const hostFromOrigin = (origin: string): string => {
|
||||
const raw = (origin ?? '').trim();
|
||||
if (!raw) return '';
|
||||
try {
|
||||
return new URL(raw).hostname;
|
||||
} catch {
|
||||
// Not a parseable URL — maybe a scheme-less origin or bare host.
|
||||
try {
|
||||
return new URL(`https://${raw}`).hostname;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -25,21 +25,27 @@ import { PuterService } from '../types.js';
|
||||
* Resolved at call time against the apps table.
|
||||
*/
|
||||
const RECOMMENDED_APP_NAMES = [
|
||||
'app-center',
|
||||
'builder',
|
||||
'editor',
|
||||
'camera',
|
||||
'recorder',
|
||||
'app-center',
|
||||
'dev-center',
|
||||
'butler',
|
||||
'editor',
|
||||
'calculator',
|
||||
'calendar',
|
||||
'code',
|
||||
'camera',
|
||||
'music-player',
|
||||
'recorder',
|
||||
'contacts',
|
||||
'browser',
|
||||
'memos',
|
||||
'audio-editor',
|
||||
'ai-image-project',
|
||||
'word-processor',
|
||||
'spreadsheet',
|
||||
'presentation',
|
||||
'pdf-editor',
|
||||
'vault',
|
||||
'basketball-tap',
|
||||
'blockup',
|
||||
'pretty-tiles',
|
||||
@@ -72,5 +78,8 @@ function toAppSummary(
|
||||
godmode: Boolean(app.godmode),
|
||||
maximize_on_start: Boolean(app.maximize_on_start),
|
||||
index_url: app.index_url,
|
||||
// An app with no owner isn't owned by a Puter user — it's an
|
||||
// "external" (origin-bootstrapped) app.
|
||||
external: app.owner_user_id == null || app.owner_user_id === '',
|
||||
};
|
||||
}
|
||||
@@ -1348,6 +1348,85 @@ describe('AuthService (integration)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
const blockOrigin = async (
|
||||
domain: string,
|
||||
includeSubdomains = false,
|
||||
) => {
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `blocked_app_origins` (`domain`, `include_subdomains`) VALUES (?, ?)',
|
||||
[domain, includeSubdomains ? 1 : 0],
|
||||
);
|
||||
(
|
||||
server.services.appOriginBlocklist as {
|
||||
invalidate: () => void;
|
||||
}
|
||||
).invalidate();
|
||||
};
|
||||
|
||||
it('appUidFromOrigin throws 403 app_blocked for a blocked exact host', async () => {
|
||||
const host = `blocked-${uuidv4()}.example.com`;
|
||||
await blockOrigin(host);
|
||||
await expect(
|
||||
authService.appUidFromOrigin(`https://${host}/`),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'app_blocked',
|
||||
});
|
||||
});
|
||||
|
||||
it('appUidFromOrigin throws for a subdomain of an include_subdomains entry', async () => {
|
||||
const apex = `evil-${uuidv4()}.example.com`;
|
||||
await blockOrigin(apex, true);
|
||||
await expect(
|
||||
authService.appUidFromOrigin(`https://app.${apex}/`),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'app_blocked',
|
||||
});
|
||||
});
|
||||
|
||||
it('appUidFromOrigin still resolves an unrelated origin', async () => {
|
||||
const uid = await authService.appUidFromOrigin(
|
||||
`https://fine-${uuidv4()}.example.com/`,
|
||||
);
|
||||
expect(uid).toMatch(/^app-/);
|
||||
});
|
||||
|
||||
it('rejects an already-issued app token once its origin is blocked', async () => {
|
||||
const user = await makeUser();
|
||||
const host = `late-block-${uuidv4()}.example.com`;
|
||||
const appUid = `app-${uuidv4()}`;
|
||||
// App row carries the to-be-blocked host as its index_url.
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
|
||||
[appUid, `n-${appUid}`, `t-${appUid}`, `https://${host}/`, 1],
|
||||
);
|
||||
const appToken = await authService.getUserAppToken(
|
||||
{
|
||||
user: {
|
||||
id: user.id,
|
||||
uuid: user.uuid,
|
||||
username: user.username,
|
||||
},
|
||||
} as Actor,
|
||||
appUid,
|
||||
);
|
||||
|
||||
// Before blocking the token authenticates normally.
|
||||
const ok = await authService.authenticate(appToken);
|
||||
expect(ok.actor?.app?.uid).toBe(appUid);
|
||||
|
||||
// After blocking the same token is rejected with the blocked signal.
|
||||
await blockOrigin(host);
|
||||
const blocked = await authService.authenticate(appToken);
|
||||
expect(blocked.actor).toBeUndefined();
|
||||
expect(blocked.blocked).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserAppToken', () => {
|
||||
it('throws 403 when actor has no user', async () => {
|
||||
await expect(
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import type { UserRow } from '../../stores/user/UserStore';
|
||||
import type { LayerInstances } from '../../types';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { Span } from '../../util/span.js';
|
||||
import type { puterServices } from '../index';
|
||||
import { PuterService } from '../types';
|
||||
import { FULL_API_ACCESS } from '../permission/consts';
|
||||
@@ -50,6 +51,13 @@ export interface AuthResult {
|
||||
actor?: Actor;
|
||||
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.
|
||||
*/
|
||||
blocked?: { reason?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +144,7 @@ export class AuthService extends PuterService {
|
||||
return { authId: decoded.auth_id };
|
||||
}
|
||||
|
||||
@Span('auth.authenticate')
|
||||
async authenticate(
|
||||
token: string,
|
||||
ctx: { ip?: string; userAgent?: string } = {},
|
||||
@@ -891,6 +900,19 @@ export class AuthService extends PuterService {
|
||||
const event = { origin: aliased };
|
||||
await this.clients.event?.emitAndWait('app.from-origin', event, {});
|
||||
|
||||
// Blocked origins can't acquire an app token (or have one minted /
|
||||
// checked / granted), so the app loses every path to Puter resources.
|
||||
const block = await this.services.appOriginBlocklist.isOriginBlocked(
|
||||
event.origin,
|
||||
);
|
||||
if (block.blocked) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This app is not allowed to access Puter resources',
|
||||
{ legacyCode: 'app_blocked' },
|
||||
);
|
||||
}
|
||||
|
||||
const canonicalUid = await this.#findCanonicalAppUidForOrigin(
|
||||
event.origin,
|
||||
);
|
||||
@@ -1784,6 +1806,21 @@ export class AuthService extends PuterService {
|
||||
const app = await this.stores.app.getByUid(decoded.app_uid);
|
||||
if (!app) return { invalid: true };
|
||||
|
||||
// Reject already-issued app tokens whose app origin is now blocked, so
|
||||
// a block takes effect immediately rather than waiting for token
|
||||
// expiry. The app's `index_url` host is the same origin checked at
|
||||
// token acquisition.
|
||||
const indexUrl = (app as { index_url?: unknown }).index_url;
|
||||
if (typeof indexUrl === 'string' && indexUrl) {
|
||||
const block =
|
||||
await this.services.appOriginBlocklist.isOriginBlocked(
|
||||
indexUrl,
|
||||
);
|
||||
if (block.blocked) {
|
||||
return { blocked: { reason: block.reason } };
|
||||
}
|
||||
}
|
||||
|
||||
let rawRow: SessionRow | null = null;
|
||||
if (decoded.session_uid) {
|
||||
rawRow = (await this.stores.session.getByUuidAny(
|
||||
@@ -1930,7 +1967,9 @@ export class AuthService extends PuterService {
|
||||
#buildUserActor(user: UserRow, session: SessionRow | null): Actor {
|
||||
return {
|
||||
user: this.#actorUserFromRow(user),
|
||||
session: session ? { uid: session.uuid } : null,
|
||||
session: session
|
||||
? { uid: session.uuid, kind: session.kind ?? null }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1945,7 +1984,9 @@ export class AuthService extends PuterService {
|
||||
uid: app.uid,
|
||||
id: app.id,
|
||||
},
|
||||
session: session ? { uid: session.uuid } : null,
|
||||
session: session
|
||||
? { uid: session.uuid, kind: session.kind ?? null }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user