mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -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",
|
||||
|
||||
@@ -75,6 +75,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();
|
||||
|
||||
@@ -79,6 +79,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(
|
||||
@@ -160,6 +161,7 @@ export const handleWhoami = async (
|
||||
delete details.desktop_bg_color;
|
||||
delete details.desktop_bg_fit;
|
||||
delete details.human_readable_age;
|
||||
delete details.is_user_token;
|
||||
}
|
||||
|
||||
if (actor.app) {
|
||||
|
||||
Generated
+4791
-5043
File diff suppressed because it is too large.
Load diff
+8
-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",
|
||||
@@ -84,9 +84,13 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,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 {
|
||||
|
||||
@@ -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`);
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,48 @@ 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('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 +2047,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 +2378,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 +2668,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();
|
||||
|
||||
@@ -23,6 +23,7 @@ import crypto from 'node:crypto';
|
||||
import { v4 as uuidv4 } 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,18 @@ 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;
|
||||
const RESERVED_USERNAMES = new Set([
|
||||
'admin',
|
||||
'administrator',
|
||||
@@ -378,11 +390,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 +406,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 +522,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 +625,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 +765,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 +1055,154 @@ 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), 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 (PII); the
|
||||
* user + country are enough to correlate.
|
||||
*/
|
||||
private smsSendError(
|
||||
statusCode: number,
|
||||
clientMessage: string,
|
||||
reason: string,
|
||||
ctx: {
|
||||
userId?: number;
|
||||
userUid?: string;
|
||||
country?: string;
|
||||
detail?: unknown;
|
||||
},
|
||||
options: HttpErrorOptions = {},
|
||||
): 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))}` : ''),
|
||||
);
|
||||
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 +1219,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 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 +1238,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 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 +1305,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 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 +1339,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 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 +1358,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 +1369,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 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 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 +1427,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 +1512,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 +1529,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 +1581,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 +1636,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 +1702,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.',
|
||||
@@ -1486,6 +1738,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 +1761,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 {
|
||||
@@ -1529,11 +1794,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 +2307,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 +2995,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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -139,6 +139,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]
|
||||
|
||||
@@ -137,6 +137,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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ describe('SystemController GET /whoarewe', () => {
|
||||
expect(captured.body).toMatchObject({
|
||||
name: 'Puter',
|
||||
environment: 'dev',
|
||||
disable_user_signup: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -79,6 +79,16 @@ 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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
];
|
||||
@@ -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(
|
||||
|
||||
@@ -165,7 +165,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 +249,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,
|
||||
|
||||
+49
-9
@@ -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';
|
||||
@@ -481,7 +483,6 @@ export class PuterServer {
|
||||
createAuthProbe({
|
||||
authService,
|
||||
cookieName: this.#config.cookie_name,
|
||||
kvStore: this.stores.kv,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -729,11 +730,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;
|
||||
@@ -1144,9 +1147,46 @@ export class PuterServer {
|
||||
|
||||
await this.#fireOnServerStart();
|
||||
console.log('PuterServer has fully booted.');
|
||||
// Auto-launch the browser on dev boot (matches v1 WebServerService).
|
||||
// Opt out via `no_browser_launch: true` in config.
|
||||
if (this.#config.env === 'dev' && !cfg.no_browser_launch) {
|
||||
|
||||
// CLI: `--server` (optionally `--puter-backend=<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,25 @@ 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',
|
||||
'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 +76,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(
|
||||
|
||||
@@ -50,6 +50,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 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -891,6 +898,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 +1804,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(
|
||||
|
||||
@@ -211,4 +211,25 @@ describe('OIDCService.createUserFromOIDC', () => {
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/verify/i);
|
||||
});
|
||||
|
||||
it('refuses to create a fresh account when registration is disabled', async () => {
|
||||
const oidcConfig = server.services.oidc.config as {
|
||||
disable_user_signup?: boolean;
|
||||
};
|
||||
const prev = oidcConfig.disable_user_signup;
|
||||
oidcConfig.disable_user_signup = true;
|
||||
try {
|
||||
const result = await runWithContext({ req }, () =>
|
||||
oidc().createUserFromOIDC('microsoft', {
|
||||
sub: 'disabled-sub',
|
||||
email: '[email protected]',
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/disabled/i);
|
||||
} finally {
|
||||
oidcConfig.disable_user_signup = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -443,6 +443,13 @@ export class OIDCService extends PuterService {
|
||||
};
|
||||
}
|
||||
|
||||
if (this.config.disable_user_signup) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'User registration is disabled.',
|
||||
};
|
||||
}
|
||||
|
||||
// Generate a unique username
|
||||
let username: string;
|
||||
let attempts = 0;
|
||||
|
||||
@@ -188,6 +188,28 @@ describe('TokenService.sign', () => {
|
||||
expect(payload.exp).toBeUndefined();
|
||||
});
|
||||
|
||||
it('signs from config even before onServerStart runs (boot-window race)', () => {
|
||||
// The http socket starts accepting connections before onServerStart
|
||||
// finishes, so a login can arrive while the service is still booting.
|
||||
// Secrets are read straight from config (not copied in onServerStart),
|
||||
// so signing must work without onServerStart having run — otherwise
|
||||
// jwt.sign would get an empty secret and throw `secretOrPrivateKey
|
||||
// must have a value`, surfacing as a 500.
|
||||
const config = {
|
||||
jwt_secret: V1_SECRET,
|
||||
jwt_secret_v2: V2_SECRET,
|
||||
} as ConstructorParameters<typeof TokenService>[0];
|
||||
const [clients, stores, services] = [{}, {}, {}] as [
|
||||
ConstructorParameters<typeof TokenService>[1],
|
||||
ConstructorParameters<typeof TokenService>[2],
|
||||
ConstructorParameters<typeof TokenService>[3],
|
||||
];
|
||||
const svc = new TokenService(config, clients, stores, services);
|
||||
// Note: onServerStart() intentionally NOT called.
|
||||
const token = svc.sign('auth', { type: 'session' });
|
||||
expect(() => jwt.verify(token, V2_SECRET)).not.toThrow();
|
||||
});
|
||||
|
||||
it('caller cannot override the `kid` routing discriminant', () => {
|
||||
const svc = createTokenService();
|
||||
const token = svc.sign(
|
||||
|
||||
@@ -190,9 +190,22 @@ const SHIPPED_PLACEHOLDER_SECRETS = new Set([
|
||||
]);
|
||||
|
||||
export class TokenService extends PuterService {
|
||||
#secretV2: string = '';
|
||||
#secretLegacy: string = '';
|
||||
#allowV1Tokens = true;
|
||||
// Secrets are read straight from `this.config`, which is populated at
|
||||
// construction (before any onServerStart runs). They are deliberately NOT
|
||||
// copied into fields during onServerStart: the http socket starts
|
||||
// accepting connections before onServerStart finishes, so a copied field
|
||||
// would still be empty for any request that lands in the boot window —
|
||||
// producing a cryptic `secretOrPrivateKey must have a value` 500 on login.
|
||||
// Reading config directly closes that window entirely.
|
||||
get #secretV2(): string {
|
||||
return this.config.jwt_secret_v2 ?? '';
|
||||
}
|
||||
get #secretLegacy(): string {
|
||||
return this.config.jwt_secret ?? '';
|
||||
}
|
||||
get #allowV1Tokens(): boolean {
|
||||
return this.config.allow_v1_tokens !== false;
|
||||
}
|
||||
|
||||
override onServerStart(): void {
|
||||
const secretV2 = this.config.jwt_secret_v2;
|
||||
@@ -227,12 +240,11 @@ export class TokenService extends PuterService {
|
||||
}
|
||||
}
|
||||
}
|
||||
this.#secretV2 = secretV2;
|
||||
// Legacy secret is optional in fresh installs (no v1 tokens to verify),
|
||||
// but every existing deployment carries one. Don't fail boot if it's
|
||||
// missing — instead, refuse to verify v1 tokens later.
|
||||
this.#secretLegacy = this.config.jwt_secret ?? '';
|
||||
this.#allowV1Tokens = this.config.allow_v1_tokens !== false;
|
||||
// Note: secrets are exposed via getters over `this.config` (see above),
|
||||
// not copied into fields here. onServerStart only validates them at
|
||||
// boot — fail fast on a missing v2 secret or a shipped placeholder.
|
||||
// The legacy `jwt_secret` stays optional (fresh installs have no v1
|
||||
// tokens to verify); a missing one is handled at verify time.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
FSEntry,
|
||||
FSEntryCreateInput,
|
||||
FSEntryWriteInput,
|
||||
hasNoBackingS3Object,
|
||||
PendingUploadCreateInput,
|
||||
PendingUploadSession,
|
||||
} from '../../stores/fs/FSEntry.js';
|
||||
@@ -2806,6 +2807,24 @@ export class FSService extends PuterService {
|
||||
{ legacyCode: 'shortcut_target_not_found' },
|
||||
);
|
||||
}
|
||||
// Empty files (created via `touch`/`createNonFileEntry` with kind
|
||||
// 'empty-file') have no backing S3 object — `bucket` is null. Reading
|
||||
// one would throw NoSuchKey and, worse, trip #handleGhostFile, which
|
||||
// deletes the entry as if it were an orphan. Return an empty stream
|
||||
// instead. (A real file whose object is genuinely missing keeps a
|
||||
// non-null bucket, so it still falls through to the ghost path below.)
|
||||
if (hasNoBackingS3Object(entry)) {
|
||||
return {
|
||||
body: Readable.from([]),
|
||||
contentLength: 0,
|
||||
contentType: null,
|
||||
contentRange: null,
|
||||
etag: null,
|
||||
lastModified: entry.modified
|
||||
? new Date(entry.modified * 1000)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
const objectKey = entry.uuid;
|
||||
try {
|
||||
return await this.stores.s3Object.getObjectStream(
|
||||
@@ -3646,20 +3665,58 @@ export class FSService extends PuterService {
|
||||
});
|
||||
}
|
||||
|
||||
// Empty files (created via `touch`/`createNonFileEntry` with kind
|
||||
// 'empty-file') have no backing S3 object — `bucket` is null and there
|
||||
// is nothing to CopyObject. Issuing one would throw NoSuchKey, so clone
|
||||
// the source as another empty-file entry instead of touching S3.
|
||||
if (hasNoBackingS3Object(source)) {
|
||||
return this.stores.fsEntry.createNonFileEntry({
|
||||
userId,
|
||||
parent: destinationParent,
|
||||
name: newName,
|
||||
kind: 'empty-file',
|
||||
metadata: source.metadata,
|
||||
thumbnail: source.thumbnail,
|
||||
associatedAppId: source.associatedAppId,
|
||||
isPublic: source.isPublic,
|
||||
immutable: source.immutable,
|
||||
});
|
||||
}
|
||||
|
||||
const newUuid = uuidv4();
|
||||
const sourceObjectKey = source.uuid;
|
||||
const resolvedBucket = this.stores.s3Object.resolveBucket(
|
||||
source.bucket,
|
||||
);
|
||||
await this.stores.s3Object.copyObject(
|
||||
{
|
||||
sourceBucket: resolvedBucket,
|
||||
sourceKey: sourceObjectKey,
|
||||
destinationBucket: resolvedBucket,
|
||||
destinationKey: newUuid,
|
||||
},
|
||||
this.stores.s3Object.resolveRegion(source.bucketRegion),
|
||||
);
|
||||
// A ghost file — DB row present with a non-null bucket but its backing
|
||||
// S3 object gone — would make CopyObject throw NoSuchKey and bubble up
|
||||
// as a 500. Mirror `readContent`: clean up the orphan and surface a
|
||||
// 404 instead. (`hasNoBackingS3Object` above only covers legitimately
|
||||
// empty files, which keep a null bucket.)
|
||||
try {
|
||||
await this.stores.s3Object.copyObject(
|
||||
{
|
||||
sourceBucket: resolvedBucket,
|
||||
sourceKey: sourceObjectKey,
|
||||
destinationBucket: resolvedBucket,
|
||||
destinationKey: newUuid,
|
||||
},
|
||||
this.stores.s3Object.resolveRegion(source.bucketRegion),
|
||||
);
|
||||
} catch (err) {
|
||||
if (isNoSuchKeyError(err)) {
|
||||
await this.#handleGhostFile(source, sourceObjectKey);
|
||||
throw new HttpError(404, 'File contents are missing', {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
cause: err,
|
||||
fields: {
|
||||
path: source.path,
|
||||
uid: source.uuid,
|
||||
},
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const nextMetadata = this.#sanitizeClientMetadata(source.metadata);
|
||||
|
||||
|
||||
@@ -171,6 +171,12 @@ export class PuterHomepageService extends PuterService {
|
||||
const guiParams: Record<string, unknown> = {
|
||||
...this.#guiParams,
|
||||
...(this.config.gui_params ?? {}),
|
||||
// The config flag wins, but an operator-set
|
||||
// `gui_params.disable_temp_users` must survive the override.
|
||||
disable_temp_users: Boolean(
|
||||
this.config.disable_user_signup ||
|
||||
this.config.gui_params?.disable_temp_users,
|
||||
),
|
||||
domain: this.config.domain,
|
||||
env,
|
||||
api_base_url: this.config.api_base_url,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { ACLService } from './acl/ACLService';
|
||||
import { AppOriginBlocklistService } from './abuse/AppOriginBlocklistService';
|
||||
import { AppPermissionService } from './apps/AppPermissionService';
|
||||
import { RecommendedAppsService } from './apps/RecommendedAppsService';
|
||||
import { SuggestedAppsService } from './apps/SuggestedAppsService';
|
||||
@@ -47,6 +48,7 @@ import type { IPuterServiceRegistry } from './types';
|
||||
declare module './types' {
|
||||
interface IPuterServiceInstances {
|
||||
metering: MeteringService;
|
||||
appOriginBlocklist: AppOriginBlocklistService;
|
||||
permission: PermissionService;
|
||||
acl: ACLService;
|
||||
token: TokenService;
|
||||
@@ -77,6 +79,10 @@ declare module './types' {
|
||||
// BroadcastService is independent — only needs the event client.
|
||||
export const puterServices = {
|
||||
metering: MeteringService,
|
||||
// Declared before `auth` so AuthService sees it as a prior peer — it
|
||||
// queries the blocklist on app-token acquisition and per-request app
|
||||
// token validation.
|
||||
appOriginBlocklist: AppOriginBlocklistService,
|
||||
permission: PermissionService,
|
||||
acl: ACLService,
|
||||
token: TokenService,
|
||||
|
||||
@@ -787,6 +787,45 @@ describe('MeteringService', () => {
|
||||
expect(await target.hasAnyUsage(actor)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not double-charge same-month overage against remaining (usage total + consumed credits)', async () => {
|
||||
const sub = await target.getActorSubscription(actor);
|
||||
await target.updateAddonCredit(actor.user.uuid, 5_000_000);
|
||||
|
||||
// Exhaust the allowance, then overspend by 1_000_000 — the overage
|
||||
// is consumed from purchased credits.
|
||||
await target.incrementUsage(
|
||||
actor,
|
||||
'kv:read',
|
||||
1,
|
||||
sub.monthUsageAllowance,
|
||||
);
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 1_000_000);
|
||||
await waitFor(async () => {
|
||||
const addons = await target.getActorAddons(actor);
|
||||
expect(addons.consumedPurchaseCredits).toBe(1_000_000);
|
||||
});
|
||||
|
||||
// The overage already lives in both this month's usage total and
|
||||
// consumedPurchaseCredits; remaining must only be reduced once.
|
||||
const allowed = await target.getAllowedUsage(actor);
|
||||
expect(allowed.remaining).toBe(4_000_000);
|
||||
});
|
||||
|
||||
it('counts consumed credits from prior months against the credit pool only', async () => {
|
||||
// Simulate a prior-month overage: consumed credits exist but the
|
||||
// current month has no usage (monthly usage keys roll over).
|
||||
await target.updateAddonCredit(actor.user.uuid, 5_000_000);
|
||||
await server.stores.kv.incr({
|
||||
key: `${POLICY_PREFIX}:actor:${actor.user.uuid}:addons`,
|
||||
pathAndAmountMap: { consumedPurchaseCredits: 2_000_000 },
|
||||
});
|
||||
|
||||
const allowed = await target.getAllowedUsage(actor);
|
||||
expect(allowed.remaining).toBe(
|
||||
allowed.monthUsageAllowance + 3_000_000,
|
||||
);
|
||||
});
|
||||
|
||||
it('hasEnoughCredits compares remaining against the requested amount', async () => {
|
||||
await target.updateAddonCredit(actor.user.uuid, 1_000);
|
||||
expect(await target.hasEnoughCredits(actor, 100)).toBe(true);
|
||||
@@ -835,18 +874,6 @@ describe('MeteringService', () => {
|
||||
const { res } = await server.stores.kv.get({ key });
|
||||
expect(res).toMatchObject({ purchasedCredits: 250 });
|
||||
});
|
||||
|
||||
it('persists lastUpdated after an increment', async () => {
|
||||
const userId = actor.user.uuid;
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 50);
|
||||
await waitFor(async () => {
|
||||
const { res } = await server.stores.kv.get({
|
||||
key: `${METRICS_PREFIX}:actor:${userId}:lastUpdated`,
|
||||
});
|
||||
expect(typeof res).toBe('number');
|
||||
expect(res).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Resolver registration ────────────────────────────────────────
|
||||
|
||||
@@ -238,14 +238,6 @@ export class MeteringService extends PuterService {
|
||||
}),
|
||||
);
|
||||
|
||||
this.handleAuxPromise(
|
||||
'lastUpdated',
|
||||
this.stores.kv.set({
|
||||
key: `${METRICS_PREFIX}:actor:${userId}:lastUpdated`,
|
||||
value: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
const [actorUsages, actorSubscription, actorAddons] =
|
||||
await Promise.all([
|
||||
actorUsagesPromise,
|
||||
@@ -401,13 +393,6 @@ export class MeteringService extends PuterService {
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.handleAuxPromise(
|
||||
'lastUpdated',
|
||||
this.stores.kv.set({
|
||||
key: `${METRICS_PREFIX}:actor:${userId}:lastUpdated`,
|
||||
value: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
const [actorUsages, actorSubscription, actorAddons] =
|
||||
await Promise.all([
|
||||
@@ -586,13 +571,6 @@ export class MeteringService extends PuterService {
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.handleAuxPromise(
|
||||
'lastUpdated',
|
||||
this.stores.kv.set({
|
||||
key: `${METRICS_PREFIX}:actor:${userId}:lastUpdated`,
|
||||
value: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
@@ -649,13 +627,21 @@ export class MeteringService extends PuterService {
|
||||
],
|
||||
);
|
||||
|
||||
const remaining = Math.max(
|
||||
// Overage past the allowance is already charged to purchased credits
|
||||
// via consumedPurchaseCredits, so the allowance and the credit pool
|
||||
// must be netted separately — subtracting month usage AND consumed
|
||||
// credits from one combined pool would charge the overage twice.
|
||||
const remainingAllowance = Math.max(
|
||||
0,
|
||||
(userSubscription.monthUsageAllowance || 0) +
|
||||
(addons?.purchasedCredits || 0) -
|
||||
(currentMonthUsage.usage.total || 0) -
|
||||
(userSubscription.monthUsageAllowance || 0) -
|
||||
(currentMonthUsage.usage.total || 0),
|
||||
);
|
||||
const remainingPurchasedCredits = Math.max(
|
||||
0,
|
||||
(addons?.purchasedCredits || 0) -
|
||||
(addons?.consumedPurchaseCredits || 0),
|
||||
);
|
||||
const remaining = remainingAllowance + remainingPurchasedCredits;
|
||||
|
||||
return {
|
||||
remaining,
|
||||
|
||||
@@ -53,6 +53,17 @@ export interface FSEntry {
|
||||
suggestedApps: unknown[]; // TODO DS: type with app row
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an entry has no backing S3 object. Empty files (size 0) created
|
||||
* via `touch`/`createNonFileEntry` never upload to S3 and leave `bucket` null;
|
||||
* real files always store a non-null bucket on write, so a null bucket on a
|
||||
* size-0 entry reliably means there is nothing in S3 to read or copy. A real
|
||||
* file whose object went missing keeps its bucket, so this stays false and the
|
||||
* usual missing-object handling still applies.
|
||||
*/
|
||||
export const hasNoBackingS3Object = (entry: FSEntry): boolean =>
|
||||
(entry.size ?? 0) === 0 && entry.bucket === null;
|
||||
|
||||
export interface FSEntrySubdomain {
|
||||
uuid: string;
|
||||
address: string; // `${config.protocol}://${subdomain}.${'puter.site'|'puter.work'}` depending on wether dir or file
|
||||
|
||||
@@ -87,4 +87,11 @@ export class OIDCStore extends PuterStore {
|
||||
[userId, provider],
|
||||
);
|
||||
}
|
||||
|
||||
async unlinkAllByUserId(userId) {
|
||||
await this.clients.db.write(
|
||||
'DELETE FROM `user_oidc_providers` WHERE `user_id` = ?',
|
||||
[userId],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -366,6 +366,50 @@ describe('SystemKVStore', () => {
|
||||
expect(result.res).toMatchObject({ page: { views: 4 } });
|
||||
});
|
||||
|
||||
it('folds expireAt into the incr and stamps it once', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 10;
|
||||
// First bump creates the counter and stamps the (already-elapsed)
|
||||
// ttl in the same write — no separate expireAt call.
|
||||
await target.incr(
|
||||
{ key: 'ttlCounter', pathAndAmountMap: { hits: 1 }, expireAt: past },
|
||||
opts,
|
||||
);
|
||||
const result = await target.get({ key: 'ttlCounter' }, opts);
|
||||
expect(result.res).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the first expireAt stamp across later bumps (if_not_exists)', async () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600;
|
||||
await target.incr(
|
||||
{ key: 'ttlKeep', pathAndAmountMap: { hits: 1 }, expireAt: future },
|
||||
opts,
|
||||
);
|
||||
// A later bump passing an already-elapsed ttl must NOT override the
|
||||
// first stamp, so the counter stays visible.
|
||||
const past = Math.floor(Date.now() / 1000) - 10;
|
||||
await target.incr(
|
||||
{ key: 'ttlKeep', pathAndAmountMap: { hits: 1 }, expireAt: past },
|
||||
opts,
|
||||
);
|
||||
const result = await target.get({ key: 'ttlKeep' }, opts);
|
||||
expect(result.res).toMatchObject({ hits: 2 });
|
||||
});
|
||||
|
||||
it('creates nested intermediate maps lazily on the first bump, then accumulates', async () => {
|
||||
// First bump into a missing nested parent must still build the map
|
||||
// (optimistic path: the direct update fails, createPaths runs, retry
|
||||
// succeeds), and subsequent bumps keep accumulating.
|
||||
await target.incr(
|
||||
{ key: 'lazyNest', pathAndAmountMap: { 'a.b.c': 2 } },
|
||||
opts,
|
||||
);
|
||||
const after = await target.incr(
|
||||
{ key: 'lazyNest', pathAndAmountMap: { 'a.b.c': 3 } },
|
||||
opts,
|
||||
);
|
||||
expect(after.res).toMatchObject({ a: { b: { c: 5 } } });
|
||||
});
|
||||
|
||||
it('decr subtracts via the same machinery', async () => {
|
||||
await target.incr(
|
||||
{ key: 'counter3', pathAndAmountMap: { hits: 10 } },
|
||||
|
||||
@@ -506,7 +506,11 @@ export class SystemKVStore extends PuterStore {
|
||||
}
|
||||
|
||||
async incr<T extends Record<string, number>>(
|
||||
{ key, pathAndAmountMap }: { key: string; pathAndAmountMap: T },
|
||||
{
|
||||
key,
|
||||
pathAndAmountMap,
|
||||
expireAt,
|
||||
}: { key: string; pathAndAmountMap: T; expireAt?: number },
|
||||
opts?: KVOpts,
|
||||
): Promise<
|
||||
KVResult<T extends { '': number } ? number : RecursiveRecord<number>>
|
||||
@@ -529,12 +533,6 @@ export class SystemKVStore extends PuterStore {
|
||||
const actor = ensureActor(opts);
|
||||
const namespace = getNamespace(actor, opts?.appUuid);
|
||||
|
||||
const createPathsUsage = await this.createPaths(
|
||||
namespace,
|
||||
key,
|
||||
Object.keys(pathAndAmountMap),
|
||||
);
|
||||
|
||||
const setStatements = Object.entries(pathAndAmountMap).map(
|
||||
([valPath, _amt], idx) => {
|
||||
const attrName = ['value', ...valPath.split('.')]
|
||||
@@ -565,13 +563,50 @@ export class SystemKVStore extends PuterStore {
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
const response = await this.clients.dynamo.update(
|
||||
this.tableName,
|
||||
{ key, namespace },
|
||||
`SET ${setStatements.join(', ')}`,
|
||||
valueAttributeValues,
|
||||
{ ...valueAttributeNames, '#value': 'value' },
|
||||
);
|
||||
// Fold the TTL into the same UpdateItem so a counter bump is a single
|
||||
// write instead of incr + a separate expireAt. if_not_exists keeps the
|
||||
// first stamp for the key (re-stamping the same value was always a
|
||||
// no-op) — but now we don't pay for that extra write on every bump.
|
||||
if (expireAt !== undefined) {
|
||||
const ttlSeconds = Number(expireAt);
|
||||
if (Number.isNaN(ttlSeconds))
|
||||
throw new HttpError(400, 'kv: expireAt must be a number', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
setStatements.push('#ttl = if_not_exists(#ttl, :ttl)');
|
||||
valueAttributeValues[':ttl'] = ttlSeconds;
|
||||
valueAttributeNames['#ttl'] = 'ttl';
|
||||
}
|
||||
|
||||
const updateExpression = `SET ${setStatements.join(', ')}`;
|
||||
const expressionNames = { ...valueAttributeNames, '#value': 'value' };
|
||||
const runUpdate = () =>
|
||||
this.clients.dynamo.update(
|
||||
this.tableName,
|
||||
{ key, namespace },
|
||||
updateExpression,
|
||||
valueAttributeValues,
|
||||
expressionNames,
|
||||
);
|
||||
|
||||
// Most increments land on an item whose parent maps already exist (a
|
||||
// day's counter is created once, then bumped on every event), so try
|
||||
// the update directly and only pay the per-layer createPaths writes
|
||||
// when a nested parent is genuinely missing — typically the first bump
|
||||
// for a key. Mirrors the lazy ValidationException fallback in remove().
|
||||
let createPathsUsage = 0;
|
||||
let response;
|
||||
try {
|
||||
response = await runUpdate();
|
||||
} catch (e) {
|
||||
if ((e as Error)?.name !== 'ValidationException') throw e;
|
||||
createPathsUsage = await this.createPaths(
|
||||
namespace,
|
||||
key,
|
||||
Object.keys(pathAndAmountMap),
|
||||
);
|
||||
response = await runUpdate();
|
||||
}
|
||||
|
||||
const usage = writeUsage(
|
||||
Number(response.ConsumedCapacity?.CapacityUnits ?? 0) +
|
||||
|
||||
@@ -54,4 +54,38 @@ describe('UserStore', () => {
|
||||
expect(typeof cachedUser?.email_confirmed).toBe('boolean');
|
||||
expect(typeof cachedUser?.requires_email_confirmation).toBe('boolean');
|
||||
});
|
||||
|
||||
it('counts other accounts holding the same phone number', async () => {
|
||||
const phone = `+1415555${Math.floor(1000 + Math.random() * 9000)}`;
|
||||
const makeUser = async () => {
|
||||
const username = `up-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const user = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
await server.stores.user.update(user.id, { phone });
|
||||
return user;
|
||||
};
|
||||
|
||||
const first = await makeUser();
|
||||
expect(
|
||||
await server.stores.user.countOthersByPhone(phone, first.id),
|
||||
).toBe(0);
|
||||
|
||||
const second = await makeUser();
|
||||
expect(
|
||||
await server.stores.user.countOthersByPhone(phone, first.id),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await server.stores.user.countOthersByPhone(phone, second.id),
|
||||
).toBe(1);
|
||||
// A caller that isn't among the holders sees both.
|
||||
expect(await server.stores.user.countOthersByPhone(phone, -1)).toBe(2);
|
||||
// Unknown number counts zero.
|
||||
expect(
|
||||
await server.stores.user.countOthersByPhone('+19995550000', -1),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,8 @@ export interface UserRow {
|
||||
email?: string | null;
|
||||
/** True when an admin has suspended the account. */
|
||||
suspended?: boolean;
|
||||
/** When the account was suspended, as unix seconds; null while not suspended. */
|
||||
suspended_at?: number | null;
|
||||
/** True when the user has confirmed the email currently on file. */
|
||||
email_confirmed?: boolean;
|
||||
/** True for accounts that must confirm email before taking most actions. */
|
||||
@@ -255,6 +257,29 @@ export class UserStore extends PuterStore {
|
||||
return this.getById(row.id as number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count accounts other than `excludeUserId` whose confirmed `phone`
|
||||
* equals this number. Backs the cap on live accounts per phone number
|
||||
* (the row is deleted with the account, so this only counts accounts
|
||||
* that still exist — recycling velocity is rate-limited separately by
|
||||
* the abuse policy's KV log).
|
||||
*
|
||||
* Not cached — like `getByCleanEmail`, callers use this for duplicate
|
||||
* detection at write time, which needs fresh reads.
|
||||
*/
|
||||
async countOthersByPhone(
|
||||
phone: string,
|
||||
excludeUserId: number,
|
||||
): Promise<number> {
|
||||
if (!phone) return 0;
|
||||
if (!isStorableAsLatin1(phone)) return 0;
|
||||
const rows = (await this.clients.db.tryHardRead(
|
||||
'SELECT COUNT(*) AS n FROM `user` WHERE `phone` = ? AND `id` != ?',
|
||||
[phone, excludeUserId],
|
||||
)) as Array<{ n: number | string }>;
|
||||
return Number(rows[0]?.n) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic property lookup. Fast-path reads redis first (cache is
|
||||
* multi-key — every identifying property points at the same serialized
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
||||
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
|
||||
import { Resource } from '@opentelemetry/resources';
|
||||
import { resourceFromAttributes } from '@opentelemetry/resources';
|
||||
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import {
|
||||
@@ -37,7 +37,7 @@ const endpoint =
|
||||
const sampleRatio = Number(process.env.OTEL_TRACE_SAMPLE_RATIO ?? 0.05);
|
||||
|
||||
const sdk = new NodeSDK({
|
||||
resource: new Resource({
|
||||
resource: resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'puter-backend',
|
||||
[ATTR_SERVICE_VERSION]: process.env.npm_package_version ?? '0.0.0',
|
||||
'deployment.environment': process.env.NODE_ENV ?? 'development',
|
||||
|
||||
@@ -397,6 +397,13 @@ export interface IGuiParams {
|
||||
title?: string;
|
||||
short_description?: string;
|
||||
social_media_image?: string;
|
||||
/**
|
||||
* Public SDK key for Prelude's JS Signals SDK. When set, the phone-
|
||||
* verification window collects a `dispatch_id` (browser signals) and
|
||||
* forwards it to /send-confirm-phone, which passes it to Prelude's Verify
|
||||
* API so its abuse model can weigh the device. Omit to disable collection.
|
||||
*/
|
||||
preludeSdkKey?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -561,6 +568,12 @@ interface IConfigOptional {
|
||||
min_pass_length: number;
|
||||
/** When true, allow the 'system' user to log in. */
|
||||
allow_system_login: boolean;
|
||||
/**
|
||||
* When true, anonymous users cannot create new accounts or temporary
|
||||
* sessions. Existing accounts can still log in, and pre-existing
|
||||
* placeholder rows may still be claimed.
|
||||
*/
|
||||
disable_user_signup: boolean;
|
||||
/** Reject auth-gated routes unless the user has confirmed their email. */
|
||||
strict_email_verification_required: boolean;
|
||||
/**
|
||||
@@ -577,6 +590,30 @@ interface IConfigOptional {
|
||||
* signups). Requires a payments extension to actually run the $0 auth.
|
||||
*/
|
||||
always_require_card_verification: boolean;
|
||||
/**
|
||||
* Let a user who keeps getting blocked on SMS phone verification fall
|
||||
* back to credit-card verification, which clears the phone gate (and the
|
||||
* card gate too, when one is set). Off by default.
|
||||
*
|
||||
* The fallback opens after `after_attempts` SMS *send* attempts inside
|
||||
* the send rate-limit window — successful sends count too, so a user who
|
||||
* receives codes fine can still choose the card path after that many
|
||||
* requests. This trades the phone signal for a card signal; it does NOT
|
||||
* guarantee SMS actually failed. Once open, the fallback stays open for
|
||||
* 24 hours so the user can finish the card flow. Requires a payments
|
||||
* extension to run the actual card check.
|
||||
*/
|
||||
phone_verification_card_fallback: {
|
||||
enabled: boolean;
|
||||
/**
|
||||
* SMS send attempts (within the send rate-limit window) before the
|
||||
* card fallback opens. Defaults to 2 when omitted. Values above the
|
||||
* send route's rate limit (10/hour) are clamped down to it — requests
|
||||
* past the route limit never reach the attempt counter, so a higher
|
||||
* threshold could never be crossed.
|
||||
*/
|
||||
after_attempts?: number;
|
||||
};
|
||||
/** Captcha configuration. */
|
||||
captcha: { enabled: boolean; difficulty?: 'easy' | 'medium' | 'hard' };
|
||||
/** OIDC / OAuth2 providers (google + custom). */
|
||||
|
||||
@@ -152,8 +152,8 @@
|
||||
</ol>
|
||||
<span class="close-message" id="earn-money-c2a-close" data-target="#earn-money">✕</span>
|
||||
<hr>
|
||||
<span style="font-size:15px;">Questions? Contact us: <a href="mailto:hi@puter.com"
|
||||
style="outline: none;">hi@puter.com</a></span>
|
||||
<span style="font-size:15px;">Questions? Contact us: <a href="mailto:support@puter.com"
|
||||
style="outline: none;">support@puter.com</a></span>
|
||||
<a style="font-size: 14px; float: right; outline: none;" href="https://puter.com/incentive-program-terms"
|
||||
target="_blank">Incentive Program Terms</a>
|
||||
</dialog>
|
||||
|
||||
+11
-11
@@ -153,7 +153,7 @@ Pass in the `tools` parameter with the type of `web_search`.
|
||||
|
||||
```js
|
||||
{
|
||||
model: 'openai/gpt-5.2-chat',
|
||||
model: 'openai/gpt-5.3-chat',
|
||||
tools: [{type: "web_search"}]
|
||||
}
|
||||
```
|
||||
@@ -214,7 +214,7 @@ is an opaque payload; treat it as a black box and just carry it forward.
|
||||
|
||||
```js
|
||||
const resp = await puter.ai.chat(messages, {
|
||||
model: 'gpt-5.4', // or 'claude-opus-4-8' — same code
|
||||
model: 'gpt-5.5', // or 'claude-opus-4-8' — same code
|
||||
stream: true,
|
||||
compaction: { trigger_tokens: 60000 },
|
||||
});
|
||||
@@ -241,7 +241,7 @@ if ( compaction ) {
|
||||
},
|
||||
{ role: 'user', content: 'now compare the two approaches' },
|
||||
],
|
||||
{ model: 'gpt-5.4', stream: true, compaction: true }
|
||||
{ model: 'gpt-5.5', stream: true, compaction: true }
|
||||
);
|
||||
for await ( const part of next ) {
|
||||
if ( part.type === 'text' ) document.write(part.text);
|
||||
@@ -253,7 +253,7 @@ if ( compaction ) {
|
||||
|
||||
```js
|
||||
const result = await puter.ai.chat(messages, {
|
||||
model: 'gpt-5.4',
|
||||
model: 'gpt-5.5',
|
||||
compaction: { trigger_tokens: 60000 },
|
||||
});
|
||||
console.log(result.message.content);
|
||||
@@ -274,7 +274,7 @@ if ( result.compaction ) {
|
||||
},
|
||||
{ role: 'user', content: 'now compare the two approaches' },
|
||||
],
|
||||
{ model: 'gpt-5.4', compaction: true }
|
||||
{ model: 'gpt-5.5', compaction: true }
|
||||
);
|
||||
console.log(next.message.content);
|
||||
}
|
||||
@@ -369,7 +369,7 @@ Image generation works with `stream: true`. Image chunks arrive as `image` event
|
||||
|
||||
```js
|
||||
const resp = await puter.ai.chat("Draw a cat", {
|
||||
model: "gemini-2.5-flash-image",
|
||||
model: "gemini-3.1-flash-image-preview",
|
||||
stream: true,
|
||||
});
|
||||
|
||||
@@ -442,7 +442,7 @@ for await (const part of resp) {
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
const resp = await puter.ai.chat('Tell me in detail what Rick and Morty is all about.', {model: 'gemini-2.5-flash-lite', stream: true });
|
||||
const resp = await puter.ai.chat('Tell me in detail what Rick and Morty is all about.', {model: 'gemini-3.1-flash-lite', stream: true });
|
||||
for await ( const part of resp ) document.write(part?.text.replaceAll('\n', '<br>'));
|
||||
})();
|
||||
</script>
|
||||
@@ -602,7 +602,7 @@ for await (const part of resp) {
|
||||
puter.print(`Loading...`);
|
||||
puter.ai
|
||||
.chat("Summarize what the User-Pays Model is: https://docs.puter.com/user-pays-model/", {
|
||||
model: "openai/gpt-5.2-chat",
|
||||
model: "openai/gpt-5.3-chat",
|
||||
tools: [{ type: "web_search" }],
|
||||
})
|
||||
.then(puter.print);
|
||||
@@ -643,7 +643,7 @@ Policy 8 - Account Management: Each Enterprise and Ultimate customer is assigned
|
||||
},
|
||||
{ role: "user", content: question },
|
||||
],
|
||||
{ model: "claude-sonnet-4-5" }
|
||||
{ model: "claude-sonnet-4-6" }
|
||||
);
|
||||
return response.message.content[0].text;
|
||||
}
|
||||
@@ -676,7 +676,7 @@ Policy 8 - Account Management: Each Enterprise and Ultimate customer is assigned
|
||||
(async () => {
|
||||
puter.print("Generating image...<br>");
|
||||
const result = await puter.ai.chat("Draw a cute cat wearing a top hat", {
|
||||
model: "gemini-2.5-flash-image",
|
||||
model: "gemini-3.1-flash-image-preview",
|
||||
});
|
||||
puter.print(result.message.content + "<br>");
|
||||
if (result.message.images?.length > 0) {
|
||||
@@ -859,7 +859,7 @@ Policy 8 - Account Management: Each Enterprise and Ultimate customer is assigned
|
||||
}
|
||||
]
|
||||
}
|
||||
], { model: 'claude-sonnet-4', stream: true });
|
||||
], { model: 'claude-sonnet-4-6', stream: true });
|
||||
|
||||
let text = '';
|
||||
|
||||
|
||||
@@ -27,10 +27,10 @@ Example model entry:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "claude-opus-4-5",
|
||||
"id": "claude-opus-4-8",
|
||||
"provider": "claude",
|
||||
"name": "Claude Opus 4.5",
|
||||
"aliases": ["claude-opus-4-5-latest"],
|
||||
"name": "Claude Opus 4.8",
|
||||
"aliases": ["claude-opus-4-8-latest"],
|
||||
"context": 200000,
|
||||
"max_tokens": 64000,
|
||||
"cost": {
|
||||
|
||||
+36
-14
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: puter.ai.txt2img()
|
||||
description: Generate images from text prompts using AI models like GPT Image, Nano Banana, DALL-E 3, Grok Image, or FLUX.
|
||||
description: Generate images from text prompts using AI models like GPT Image, Nano Banana, Grok Image, or FLUX.
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
---
|
||||
|
||||
@@ -32,19 +32,36 @@ Additional settings for the generation request. Available options depend on the
|
||||
|--------|------|-------------|
|
||||
| `prompt` | `String` | Text description for the image generation |
|
||||
| `provider` | `String` | The AI provider to use. `'openai-image-generation' (default) \| 'gemini' \| 'together' \| 'xai' \| 'replicate-image-generation'` |
|
||||
| `model` | `String` | Image model to use (provider-specific). Defaults to `'gpt-image-1-mini'` (OpenAI) or `'grok-2-image'` when `provider: 'xai'` |
|
||||
| `model` | `String` | Image model to use (provider-specific). Defaults to `'gpt-image-1-mini'` (OpenAI) or `'grok-imagine-image'` when `provider: 'xai'` |
|
||||
| `test_mode` | `Boolean` | When `true`, returns a sample image without using credits |
|
||||
| `puter_output_path` | `String` | When set, the generated image is automatically saved to this path on the Puter filesystem. Relative paths are resolved against the app's data directory (or `~/` outside an app). The caller must have write permission to the destination |
|
||||
| `input_images` | `Array<String>` | Input image(s) for image-to-image — the canonical, cross-provider field (see below). |
|
||||
| `input_image` | `String` | Single-image shorthand for `input_images`. |
|
||||
|
||||
#### Input images (image-to-image)
|
||||
|
||||
`input_images` is the universal way to pass image-to-image inputs across providers; `input_image` is the single-image shorthand. Each entry may be a **public URL**, a **data-URI**, or **raw base64** — providers that need base64 fetch URLs server-side (SSRF-guarded), so a URL works everywhere.
|
||||
|
||||
| Provider | Multiple images? | Accepted input forms |
|
||||
|----------|------------------|----------------------|
|
||||
| OpenAI `gpt-image-*` | Yes | URL, base64 / data-URI |
|
||||
| Gemini | Yes | URL, base64 / data-URI |
|
||||
| Replicate | Yes (model-dependent) | URL, base64 / data-URI |
|
||||
| xAI `grok-imagine-*` | Up to 3 | URL, base64 / data-URI |
|
||||
| Together | Single only (400 if more than one) | URL, base64 / data-URI |
|
||||
| Cloudflare | Single only (400 if more than one) | URL, base64 / data-URI (only some models use it) |
|
||||
|
||||
#### OpenAI Options
|
||||
|
||||
Available when `provider: 'openai-image-generation'` or inferred from model (`gpt-image-2`, `gpt-image-1.5`, `gpt-image-1`, `gpt-image-1-mini`, `dall-e-3`):
|
||||
Available when `provider: 'openai-image-generation'` or inferred from model (`gpt-image-2`, `gpt-image-1.5`, `gpt-image-1`, `gpt-image-1-mini`):
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `model` | `String` | Image model to use. Available: `'gpt-image-2'`, `'gpt-image-1.5'`, `'gpt-image-1'`, `'gpt-image-1-mini'`, `'dall-e-3'` |
|
||||
| `quality` | `String` | Image quality. For GPT models: `'high'`, `'medium'`, `'low'` (default: `'low'`); `gpt-image-2` also accepts `'auto'`. For DALL-E 3: `'hd'`, `'standard'` (default: `'standard'`) |
|
||||
| `ratio` | `Object` | Aspect ratio with `w` and `h` properties. `gpt-image-2` accepts arbitrary sizes; other GPT models and DALL-E are restricted to fixed sizes |
|
||||
| `model` | `String` | Image model to use. Available: `'gpt-image-2'`, `'gpt-image-1.5'`, `'gpt-image-1'`, `'gpt-image-1-mini'` |
|
||||
| `quality` | `String` | Image quality: `'high'`, `'medium'`, `'low'` (default: `'low'`); `gpt-image-2` also accepts `'auto'` |
|
||||
| `ratio` | `Object` | Aspect ratio with `w` and `h` properties. `gpt-image-2` accepts arbitrary sizes; other GPT models are restricted to fixed sizes |
|
||||
| `input_image` | `String` | An input image for image-to-image editing — a URL or base64/data-URI (URLs are fetched server-side). |
|
||||
| `input_images` | `Array<String>` | Multiple input images (URL or base64/data-URI) for image-to-image editing. Routes the request through OpenAI's image edit endpoint. |
|
||||
|
||||
For more details, see the [OpenAI API reference](https://platform.openai.com/docs/api-reference/images/create).
|
||||
|
||||
@@ -57,16 +74,19 @@ Available when `provider: 'gemini'` or inferred from model:
|
||||
| `model` | `String` | Image model to use. |
|
||||
| `ratio` | `Object` | Aspect ratio as `{ w, h }` (e.g., `{ w: 16, h: 9 }`). |
|
||||
| `quality` | `String` | Output size tier: `'512'`, `'1K'`, `'2K'`, `'4K'` (availability varies by model) |
|
||||
| `input_images` | `Array<String>` | Base64 input images for image-to-image (Gemini models only) |
|
||||
| `input_images` | `Array<String>` | Input images for image-to-image — a URL or base64/data-URI (URLs are fetched server-side). |
|
||||
|
||||
#### xAI (Grok) Options
|
||||
|
||||
Available when `provider: 'xai'` or inferred from model (`grok-2-image`, alias `grok-image`):
|
||||
Available when `provider: 'xai'` or inferred from model (`grok-imagine-image`, alias `grok-image`):
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `model` | `String` | Image model to use. Available: `'grok-2-image'` (default) |
|
||||
| `prompt` | `String` | Text prompt for the image. Grok Image does not support quality/size overrides; pricing is $0.07 per generated image. |
|
||||
| `model` | `String` | Image model to use. Available: `'grok-imagine-image'` (default), `'grok-imagine-image-quality'` |
|
||||
| `prompt` | `String` | Text prompt for the image (or the edit instruction when input images are supplied). |
|
||||
| `quality` | `String` | Output resolution tier: `'1k'` (default) or `'2k'`. |
|
||||
| `input_image` | `String` | A public URL or base64-encoded (data-URI) input image for image-to-image editing. |
|
||||
| `input_images` | `Array<String>` | Up to 3 input images (URLs or base64/data-URI) for multi-image editing — combine subjects, transfer styles, compose scenes. Routes through xAI's image edit endpoint. |
|
||||
|
||||
#### Together Options
|
||||
|
||||
@@ -82,6 +102,8 @@ Available when `provider: 'together'` or inferred from model:
|
||||
| `seed` | `Number` | Seed used for generation. Can be used to reproduce image generations |
|
||||
| `negative_prompt` | `String` | The prompt or prompts not to guide the image generation |
|
||||
| `n` | `Number` | Number of image results to generate. Default: `1` |
|
||||
| `input_images` | `Array<String>` | Image-to-image input — **single image only** (400 if more than one). A URL is routed to `image_url`; base64/data-URI to `image_base64`. |
|
||||
| `input_image` | `String` | Single-image shorthand for `input_images`. |
|
||||
| `image_url` | `String` | URL of an image to use for image models that support it |
|
||||
| `image_base64` | `String` | Base64 encoded input image for image-to-image generation |
|
||||
| `mask_image_url` | `String` | URL of mask image for inpainting |
|
||||
@@ -102,8 +124,8 @@ Available when `provider: 'replicate-image-generation'` or inferred from model:
|
||||
|--------|------|-------------|
|
||||
| `model` | `String` | Model id (e.g. `'black-forest-labs/flux-schnell'`, `'leonardoai/lucid-origin'`). |
|
||||
| `ratio` | `Object` | Aspect ratio as `{ w, h }` (e.g., `{ w: 16, h: 9 }`). |
|
||||
| `input_image` | `String` | URL of an input image for image-to-image generation. |
|
||||
| `input_images` | `Array<String>` | Array of input image URLs for multi-image generation. |
|
||||
| `input_image` | `String` | Input image for image-to-image generation — a URL or base64/data-URI. |
|
||||
| `input_images` | `Array<String>` | Input images (URL or base64/data-URI) for multi-image generation. |
|
||||
|
||||
##### Per-model options
|
||||
|
||||
@@ -173,7 +195,7 @@ A `Promise` that resolves to an `HTMLImageElement`. The element’s `src` points
|
||||
<script>
|
||||
// Generate an image of a cat playing piano using a specific model and quality set to low
|
||||
puter.ai.txt2img("a cat playing the piano", {
|
||||
model: "gpt-image-1.5",
|
||||
model: "gpt-image-2",
|
||||
quality: "low"
|
||||
}).then((image)=>{
|
||||
document.body.appendChild(image);
|
||||
@@ -191,7 +213,7 @@ A `Promise` that resolves to an `HTMLImageElement`. The element’s `src` points
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
puter.ai.txt2img("a cat playing piano", {
|
||||
model: "gemini-2.5-flash-image-preview",
|
||||
model: "gemini-3.1-flash-image-preview",
|
||||
input_image: "iVBORw0KGgoAAAANSUhEUgAAAFsAAABbCAYAAAAcNvmZAAABWGlDQ1BJQ0MgUHJvZmlsZQAAKJF1kL1LA0EQxV/0JPiFESwtrjQSJcZolyImEkSLEBVNusvmvAiXuFxO1P/AQls7ITaCoNgI14qF2AsqVhYiWlkI12hYZxP1EsWB2fnxeDM7DNCmaJybCoBS2bYyqSl1OZtT/S/oRB8C9A5rrMLj6fQcWfBdW8O9gU/W6xE56+n0Yrc3tn+yfRByq8nH3F9/S3QV9Aqj+kEZYtyyAd8QcXrD5pI3iQcsWop4R7LR4KrkfIPP6p6FTIL4ijjAilqB+E7OzDfpRhOXzHX2tYPcvkcvL85LnXIQ05hFBFGMIQsVqX+80bo3gTVwbMHCKgwUYVNHnBQOEzrxDMpgGEWIOIIw5YS88e/beZpxBEw+EBx7mp4EnFf6WvO04DPQHwYuVa5Z2s9Ffa5SWRmPNLjbATr2hHhbAvxBoHYrxLsjRO0QaL8Hzt1PBAlkAaSoB8oAAABWZVhJZk1NACoAAAAIAAGHaQAEAAAAAQAAABoAAAAAAAOShgAHAAAAEgAAAESgAgAEAAAAAQAAAFugAwAEAAAAAQAAAFsAAAAAQVNDSUkAAABTY3JlZW5zaG904ZG7uwAAAdRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDYuMC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+OTE8L2V4aWY6UGl4ZWxZRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFhEaW1lbnNpb24+OTE8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpVc2VyQ29tbWVudD5TY3JlZW5zaG90PC9leGlmOlVzZXJDb21tZW50PgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4K4RUGBAAAG9hJREFUeAHtXHt8VdWV/s65r4TwSAgQAiG85A2C2kIpFigoCFWoL1oftba1M/VFZ2gtU7XKtGXq0PZXR6rT4ovWKdWqg8hItYDCIA95KE8Bi7wSCBDCK+QmN/fec+b79r0nXtNUwNxk8sdd+Z2c195r7/3ttdZee+19roWCDS4y1CwI2M1SSqYQg0AG7GYUhAzYGbCbEYFmLCoj2RmwmxGBZiwqI9kZsJsRgWYsKiPZGbCbEYFmLCoj2RmwmxGBZiwqI9kZsJsRgWYsKiPZGbCbEYFmLKqFSHbK+oUuXZ/+AZbDc5wHzyZJTeK56088Qyxxb14qrRIl0ytP3XUynfi6arKXzkvLR81AqnULoSgxCLEuAoKgugEeFq8JjivgdOTyqGUnJEGH3usQ8b1Jz3wmrZ4pL99b5G2Rr87mnTpT/EQe+B6fxNOm+N9ywJbEWZFEG41kn+Y9gRWYVjaPLOJTxbM6IwmiUkv6jaQrv9IrDQ+R24rXulAnsqmmM3htNEZNF6/moxYCtsAjoPZh4hLHZQPiuKR/G3Tr1BEnzxzHzr+exNYdZ1B2Mp/IEEDkMT2l00cJd6p5fRadioBLBkZR3D4P+fkd4Q+GUH60EqWHSvHudot5WzM9gZbGOEECrqarJ5oP8DSB/UkV9tQzNY137TWW922P4we3FuC+ewaiXR4Bocr7CEZcZtX2oyZci1f/vBs/+flu7D5IG2y7aJMXxR3TCnD7zRejb3EubF8cPkqtZbPzZCXsOFx3JGojNt7/sAxPzd+FPy0pxwkBj7Y8ZHKS4wOvPgLeq7N5mLZ/VuNX19kw0zKhokomKyqz4PKZzcZIbZ0yvjuBboUhFBT44RDf8uNVKDlShW4dg1j1ylfQuTuTxHMQ8FNiY35mpyz4Y3As8rRiBpZTZx3c98BS5OXlYtb9Y9AqxHfsEVcMVQ+W5dhKbwtrWD6W76hOem6j8kQN7v/XVfjNS8f4qDefy0RpvKC0Q7Zd7UiTDJJTKqUBbDVSh4iNMvZWjdMt7W5OOb4ysROuvboYw4cVomPbIMFkMg564UgcFccpwcFadCwiyGxnkKBGnRhOhbOx/f2jKChsix5dchDiSwouS7Bw1qml1Q4gO2DTDPMhbXE85oNDwE9X2/hwz3FU10TQv29XtG8XZ38zl02JZ6c5rF80EsPcX2/C/fP2AJXsYUs2XYBzXBDQrINKSjelAWxJgkiV0yFEONDlnsJjP7wIt107AK1DLmxKmqQrZsTNpZTadNwk9REC74MvpsEtgBVrj+HfH9uNZatP8r4DjzOUYhdfv6EjHrzvUuRmBRFXNoITICaReBCL3jyAFxcdwGurqlB9qpJ5OvJgeVYFkOXD5JEdMGVSe9w+rR98tO+wWtEDdPDWqqOYeMcajqkXMb0EJunJGK8l/dKdBrCNcUxWVkBXo2f34/iveWPw2f55lBMXEaJiU1398SjvYtR6Gz5KpayLRam0BAylckdJJQZPfBsIayCUm8bGS71lIqxj+P2jHXD91B4IZeUjznxnqyzM+ukazP09TZTLjrHoGrrKp05X5wrAM7yUGQNuvMrBY78ag7w2EaYIwE/eP/31G3h4zikm7ZfIZ9xKgd4iJZv1UktMw6oxaZyD558ai9ZZlTSFrVAr9aXt3rjxNBYvLcGmndU4Vk7XzHWRl9saQwZn4ctTu6JfvwJcP+1VvLOpG9tJkORzy782NjSCUZ89jOULr0WQWkBjgM17zmDcV9fj5DGla8d0TCvXzmAkbdMFVUCdZYV5rWdUhZxqvPmHwRh9SR7iwQBilS6uvuU5vLW+kOk6MQ0l3+XgacrlbRopPZKtRlISLxt2EisWTUBOVi3bzsGo1sY7G09i5sNb6X5pMlLAhnhACEhNTugZOGxgG9rLswRHEm0Aa8O0dOuoG2077sXOVdejc65jJPpoWRgjJ72B0mNF5j3/XQCRf6sSbFs+CQO75dCWR7BnfxXG3PAiyg5fmuycprHZ0rVGEkGjSrbKr8CfF01Cjo/2OpZDUEJ4Zn4JrrxhJd7dQenytU+CKEBlD3XkJM7yf2kSjFegyYsmIyDQAp1m4fapxchvV2MEPspn989ejdKjBNp4EExzIaQ81d3x7e++xYE4jhg1pVdxa1w3iVLtUgOM/y2w00+NB9sluG4VnpjdF205g4s7IdRwEPrlb7bhnx4+yBp353uCyoEsYXsFKqU7dcR35fcKYHaAiV3w0mgAzxzkbpvWgx5giN5GHBvWn8BzCymdNrXi06i6MVFRrHsnG4/P34qYes8J4JZrh1MgOKBCA6xsdvrpAsHWgJN68JaS2LX7CVw/qRh06lBtn8WLL57AzJ/tIyD0YY3NlPvAfPKXTX4VS2k2tl5Ta0qUJFmzO9lVM21n55A6FACDBtCkmJiIjZ/PWcJ0nXgvM+PVxeOr+/MgE3dpj/kvVxiX0LWrMWxQZ3TtxDpoYDUxGmlsKv/z4HuOJBcItrjJ5qoiSbJj+PpVXRAI0iULOojUZOGBRzYRG6KkmIWJVyi9APEONUJ8PNDVGR5f5eF74yqG0a+4FafefliBICrPxrB6h/KqI2TbyVfjhRkz+Mh4MDp/EqkOLJf12sZxJE5tclhWKOBDn14SDr2XtrLTTcxFLmlKez+J9TnefQqwPcDEmQ13zmDq1E7Eh5WKRbDghQ84cMk7IIDG9sr+CaBU8nh4Z76jKhuAFUSiuchvX0a/2MaP7+9P31iNt1HrRHHDxBC6dN7G+6PMxPyGhQDhcd42XM1mh8VD2PzeQdjUQIvgDujBDoQCYKy7N/M1M2HVv34b+OgC6VOArSxqYbIC/goMvrgL/FaQcYlsvLHsEN+xIfS3E2kkhUp/DtJkRx3Ghk65qgZb1k3CvP/4HMaObMuSAuTAWWaujXm/+jI2LL0Rt3xZQB9gHkm4XDXViyCdkzzQlNbPOEmYEy6ZrgAmjO6F4sKd7GxJNetj0f+2NJ6kx4ZfINgCLRU4B9OmFLGyPsSo+pEYB7D3vQFGNpmAGzvsNZCPGiTylARllePV54rwwjPD0THb4qBIkKPZiBAIl/EON55F4P0oaB/C03OvxLO/6o8OeR8wryYlaook/FykuiRNFmeKLjvJpSbV+qOYOmUw1v/lm7jmqv1MQwl3NVZIaKhZaaALBDspzXWAu+hAabPo7vn4Knw2gvIqSQUlQYexvWpYfRIfNYBpHbmAMgGl+N0v+mLiFzsxPpKNANU6Eo/hpONg0dL9+O2CHThWRVBoqtyoePowbdpgPPfrkRRQapNmieJ3TqJEJ6auLLs1DpywEI4lOtth5+a3aYX5/zkBYy6p4HvOPtnRCU8pVcjOWUiDCS4QbPEQUKJE4WLgJ7AK8kSNbWWjTWPUeI99/YqKh0BWwwWQg1tv5lR8Sm/4GWuOUkoPMDr3j99fjfzBb+Cmr+3AXTNKUdhvGaZ9Yyu27T9q+jHE6f/Y0T1xzXhO0+PqPJ7PSSybvrUxc6ze9OmHMXrscjz+2+30ieKIc8BvF2jNaf2XUNTlQKK5FkGva/c5C/i7CTw0/m6Cc72IM65snAJKoM2AfcK9E4AC1OuY+lzUYNlySo3NATF0GnMeGoFsTYio0mXHazDm6pX4/UtMd5YxD3Tl0Z28C7D4TeDSiRuweuNR0ymPPflXLF6ugY0damacvPxEko2nZmhKLLine truncated
|
||||
input_image_mime_type: "image/png"
|
||||
}).then((image)=>{
|
||||
|
||||
@@ -31,6 +31,7 @@ A string invite code created by `puter.peer.serve()`.
|
||||
`options` is an object with the following properties:
|
||||
|
||||
- `iceServers` (`RTCIceServer[]`) Custom ICE servers (STUN/TURN) to use instead of the Puter-managed relays.
|
||||
- `forceRelay` (`boolean`) Whether to force connections to route through a relay instead of attempting peer-to-peer (default). Metering charges may apply.
|
||||
|
||||
## Return value
|
||||
|
||||
@@ -41,6 +42,7 @@ A `Promise` that resolves to a `PuterPeerConnection` instance.
|
||||
- `send(data)` - Send a message to the peer. Supports strings, `Blob`, `ArrayBuffer`, or `ArrayBufferView`.
|
||||
- `close(reason)` - Close the connection.
|
||||
- `owner` (`object`) - Information about the user who created the server.
|
||||
- `peerconnection` (`RTCPeerConnection`) - the raw underlying RTC handle for this connection.
|
||||
- `open` event: Fired when the data channel is ready.
|
||||
- `message` event: Fired when a message is received (`event.data`).
|
||||
- `close` event: Fired when the connection closes (`event.reason`).
|
||||
|
||||
@@ -27,6 +27,7 @@ const server = await puter.peer.serve(options);
|
||||
`options` is an object with the following properties:
|
||||
|
||||
- `iceServers` (`RTCIceServer[]`) Custom ICE servers (STUN/TURN) to use instead of the Puter-managed relays.
|
||||
- `forceRelay` (`boolean`) Whether to force connections to route through a relay instead of attempting peer-to-peer (default). Metering charges will increase.
|
||||
|
||||
## Return value
|
||||
|
||||
|
||||
@@ -75,21 +75,22 @@ $(document).on('pathchange', function (e) {
|
||||
$(this).find('.icon').html(icons[$(this).data('icon-active')]);
|
||||
});
|
||||
|
||||
// highlight code
|
||||
$('code[class^=\'language\']').each(function () {
|
||||
var $this = $(this);
|
||||
if ( $this.attr('data-highlighted') === 'yes' ) {
|
||||
// Remove the attribute or set it to 'no'
|
||||
$this.removeAttr('data-highlighted');
|
||||
}
|
||||
// Now you can re-highlight
|
||||
else {
|
||||
try {
|
||||
hljs.configure({ ignoreUnescapedHTML: true });
|
||||
hljs.highlightElement(this);
|
||||
} catch (e) {
|
||||
console.error('Error: Failed to highlight.', e);
|
||||
}
|
||||
// Highlight code. Match the same `pre code` selector hljs.highlightAll()
|
||||
// uses on full page load — selecting only `code[class^="language"]` skips
|
||||
// fenced blocks written without a language tag, so they stayed unhighlighted
|
||||
// after client-side navigation while showing up correctly on a hard reload.
|
||||
// Scope to `.docs-content` so we only re-highlight the swapped-in docs body
|
||||
// and leave code blocks elsewhere on the page untouched.
|
||||
hljs.configure({ ignoreUnescapedHTML: true });
|
||||
$('.docs-content pre code').each(function () {
|
||||
// Skip blocks hljs has already processed so repeated pathchanges don't
|
||||
// log "Element previously highlighted" and re-run on unchanged nodes.
|
||||
if ( $(this).attr('data-highlighted') === 'yes' ) return;
|
||||
|
||||
try {
|
||||
hljs.highlightElement(this);
|
||||
} catch (e) {
|
||||
console.error('Error: Failed to highlight.', e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,8 +44,8 @@ const examples = [
|
||||
title: 'AI',
|
||||
children: [
|
||||
{
|
||||
title: 'Chat with GPT-5 nano',
|
||||
description: 'Chat with GPT-5 nano using Puter.js AI API. Run and experiment with this chatbot example directly in the playground.',
|
||||
title: 'Chat with GPT-5.4 nano',
|
||||
description: 'Chat with GPT-5.4 nano using Puter.js AI API. Run and experiment with this chatbot example directly in the playground.',
|
||||
slug: 'ai-chatgpt',
|
||||
source: '/playground/examples/ai-chatgpt.html',
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ description: Find examples of serverless applications built with Puter.js
|
||||
</a>
|
||||
<div class="example-card-desc">
|
||||
<h2 id="ai-chat"><a href="/playground/app-ai-chat/?autorun=1" target="_blank">AI Chat</a></h2>
|
||||
<p>A chat app with AI using the Puter AI module. This app is powered by OpenAI GPT-5 nano.</p>
|
||||
<p>A chat app with powered by Puter.js AI API.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,7 @@ description: Find examples of serverless applications built with Puter.js
|
||||
</a>
|
||||
<div class="example-card-desc">
|
||||
<h2 id="image-desc"><a href="/playground/app-camera/?autorun=1" target="_blank">Image Describer</a></h2>
|
||||
<p>Allows you take a picture and describe it using the Puter AI module. This app is powered by OpenAI GPT-5 Vision.</p>
|
||||
<p>Allows you to take a picture and describe it using the Puter.js AI API.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ description: Find examples of serverless applications built with Puter.js
|
||||
</a>
|
||||
<div class="example-card-desc">
|
||||
<h2 id="text-summary"><a href="/playground/app-summarizer/?autorun=1" target="_blank">Text Summarizer</a></h2>
|
||||
<p>Uses the Puter AI module to summarize a given long text. The model used in the background is GPT-5 nano.</p>
|
||||
<p>Uses the Puter.js AI API to summarize a given long text.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ Puter.js is powered by [Puter](https://github.com/HeyPuter/puter), the open-sour
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
const resp = await puter.ai.chat('Tell me in detail what Rick and Morty is all about.', {model: 'gemini-2.5-flash-lite', stream: true });
|
||||
const resp = await puter.ai.chat('Tell me in detail what Rick and Morty is all about.', {model: 'gemini-3.1-flash-lite', stream: true });
|
||||
for await ( const part of resp ) puter.print(part?.text?.replaceAll('\n', '<br>'));
|
||||
})();
|
||||
</script>
|
||||
|
||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user