mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dd8e599de | ||
|
|
a62cf80bdf | ||
|
|
841d283135 | ||
|
|
c6350f1a7b | ||
|
|
fedc38faa5 | ||
|
|
2411a2baaf | ||
|
|
81fb4ee84c | ||
|
|
24c2808a00 | ||
|
|
abc8b15d63 | ||
|
|
d767802c6a | ||
|
|
3cbc499324 | ||
|
|
8ca6ecc9ca | ||
|
|
24aac2f6d2 | ||
|
|
1eb4e36cf4 | ||
|
|
3160b99f11 | ||
|
|
589a98b12c | ||
|
|
ec3ad1cb2f | ||
|
|
c7cc64e45c | ||
|
|
0c67499dc2 | ||
|
|
34d74d36bd | ||
|
|
0bdc2197b0 | ||
|
|
7759f7afe9 | ||
|
|
bbb780eb31 | ||
|
|
8a55debdae | ||
|
|
f3e8539eaf | ||
|
|
b3b02b1ebb | ||
|
|
bdc9aa9e93 | ||
|
|
21c1e4142e | ||
|
|
03e483e372 | ||
|
|
9c90c7afde | ||
|
|
acd3df8c1b | ||
|
|
4729d733bb | ||
|
|
26d843778e | ||
|
|
80a9cd85ee | ||
|
|
63370fcfa5 | ||
|
|
631b264c1c | ||
|
|
809c303117 | ||
|
|
afad53d3f4 | ||
|
|
8b8dd19eae | ||
|
|
1fdde02f6a | ||
|
|
1627650abc | ||
|
|
4968a69d45 | ||
|
|
aa47dcd3e5 | ||
|
|
c8424f6775 | ||
|
|
b3c85c86e9 | ||
|
|
d22d419c4d | ||
|
|
0760530d0c | ||
|
|
e18a5c2b54 | ||
|
|
75385501a7 | ||
|
|
5b0205f2bf | ||
|
|
3bce3b056e | ||
|
|
f70240784f | ||
|
|
356e7b2b94 | ||
|
|
f02b4a6b73 | ||
|
|
0768978111 | ||
|
|
66093e78f3 | ||
|
|
ed531e3992 | ||
|
|
53e27f3d41 | ||
|
|
deac121675 | ||
|
|
53e6e3179b | ||
|
|
73624bf1d0 | ||
|
|
1a9e43f5e5 | ||
|
|
242a654166 | ||
|
|
41e4b582d3 | ||
|
|
2a49689fad | ||
|
|
b54b76715a | ||
|
|
d978538c9d | ||
|
|
e83e27f306 | ||
|
|
54f542ace4 |
No files matched your search
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -184,10 +184,12 @@ Recipes are tested, copy-pasteable patterns for a task, one layer above the per-
|
||||
- **Description.** Written for someone who has never heard of Puter. Start with "Learn how to", put the action first, and say what the reader's app gains: "Learn how to add file sharing to your app with Puter.js, so users can share files in their Puter account with other people." No API names, no mechanism, no list of example cases, and no "not just …" contrast.
|
||||
- **Intro.** Explain the situation and why you'd do this, tied to something the reader already knows ("`puter.kv` is your database in Puter.js"). No "Use this when" bullet list.
|
||||
- **Structure.** Get to the first working call as early as possible, then add validation, rate limits, and sign-in after it. Explain a Puter concept the first time it appears, such as what `me.puter` is or that `puter.workers.exec()` opens the Puter sign-in window. Split recipes by what the reader needs, not by technique, and give a family of recipes a shared tag.
|
||||
- **Reading order.** A recipe reads top to bottom. Each code block uses only functions and variables from blocks above it, so the reader never has to search further down the page to understand it. Start with a small version that works, and have each section add one case. When a section changes a function from earlier, show the whole new version of that function there. If the code is built up over several sections, end with a `## Full Code` block that has all of it.
|
||||
- **Link to the reference for full option lists.** Mention only the options the recipe uses, then link to the function page for the rest ("All the sort options are listed on [`puter.fs.readdir()`](/FS/readdir/)."). A copied list of options, rules, or scopes goes stale when the reference changes.
|
||||
- **Limits.** Mention a limit only when it's the reason for a pattern the recipe uses, such as one wildcard subscription instead of one per key, and link to [rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) or the function page for the number. Leave out limits that code following the recipe never reaches.
|
||||
- **Code.** Platform-neutral: just the code, with top-level `await`. No `<script>` or `<html>` wrapper, no IIFE.
|
||||
- **Tradeoffs.** Justify a choice with one scenario the reader can picture right away, in about two short paragraphs, or fold each point into the section where it matters. The description states the benefit only.
|
||||
- **Notes.** Leave out `## Notes` by default. Use it only to point to an architecturally different approach. A limit goes in the section it applies to; other facts belong on the linked reference page.
|
||||
- **Notes.** Leave out `## Notes` by default. Use it only to point to an architecturally different approach. A limit the recipe needs goes in the section it applies to, and other facts belong on the linked reference page.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@
|
||||
# config.default.json, so partial overrides work. Absent file = defaults.
|
||||
|
||||
# ---- Build stage ----
|
||||
FROM node:24-slim AS build
|
||||
FROM node:26-bookworm-slim AS build
|
||||
|
||||
WORKDIR /opt/puter
|
||||
|
||||
@@ -59,7 +59,7 @@ RUN set -e; \
|
||||
wait $pjs_pid
|
||||
|
||||
# ---- Runtime stage (slim — no build tools) ----
|
||||
FROM node:24-slim
|
||||
FROM node:26-bookworm-slim
|
||||
|
||||
WORKDIR /opt/puter
|
||||
|
||||
|
||||
+14
-3
@@ -34,7 +34,7 @@
|
||||
// Idle keep-alive timeout in ms. Must stay above the idle timeout of any
|
||||
// proxy in front, or the proxy reuses connections this server has closed
|
||||
// and its clients see 502s.
|
||||
"keep_alive_timeout": 620000,
|
||||
"keep_alive_timeout": 920000,
|
||||
"protocol": "http",
|
||||
"domain": "puter.localhost",
|
||||
// Fully-qualified externally-visible URL. Computed from protocol/domain/
|
||||
@@ -215,7 +215,9 @@
|
||||
// "bootstrapTables": true,
|
||||
"path": "",
|
||||
// Credentials. NOTE: snake_case here, unlike `s3.s3Config` below.
|
||||
// For dynamodb-local, any non-empty values work.
|
||||
// For dynamodb-local, any non-empty values work. On AWS,
|
||||
// `"useCredentialChain": true` uses the default credential chain
|
||||
// instead of keys.
|
||||
"aws": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
@@ -313,7 +315,8 @@
|
||||
// The tables ship either way and sit inert; this gates whether `/teams`
|
||||
// is registered at all, so with it off the paths do not exist rather than
|
||||
// existing and refusing. It is also the backout: turning it off removes
|
||||
// the feature without touching data.
|
||||
// the feature without touching data. Teams ships as an extension; on,
|
||||
// the server refuses to boot unless one registers it.
|
||||
"teams_enabled": false,
|
||||
//
|
||||
// Live teams one user may own. Soft-deleted ones do not count, so
|
||||
@@ -430,6 +433,8 @@
|
||||
"mistral": { "apiKey": "" },
|
||||
"xai": { "apiKey": "" },
|
||||
"moonshot": { "apiKey": "" },
|
||||
// MiniMax. One key powers chat and video generation (H3, Hailuo);
|
||||
// video calls go to the same host, beside the chat API's `/v1`.
|
||||
"minimax": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://api.minimax.io/v1"
|
||||
@@ -450,6 +455,10 @@
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://ark.ap-southeast.bytepluses.com/api/v3"
|
||||
},
|
||||
// Kling AI. One API key (not the legacy AccessKey/SecretKey pair)
|
||||
// powers video generation, image generation and TTS; `apiBaseUrl`
|
||||
// defaults to https://api-singapore.klingai.com.
|
||||
"kling": { "apiKey": "" },
|
||||
"zai": { "apiKey": "" },
|
||||
"alibaba": { "apiKey": "" },
|
||||
"hoonify": {
|
||||
@@ -488,6 +497,8 @@
|
||||
"defaultVoiceId": "",
|
||||
"speechToSpeechModelId": ""
|
||||
},
|
||||
// Both AWS providers also take `"useCredentialChain": true` in place
|
||||
// of keys, like `dynamo.aws`.
|
||||
"aws-polly": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
|
||||
@@ -45,6 +45,8 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
|
||||
- **A specific verified factor:** `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. Opt-in, and the factor must actually have been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for. `requireAnyVerified: ['phone', 'card']` is the OR of the last two: any listed factor verified at any point passes, as does a paid plan when `card` is listed (a paying account already has a card on file). Otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on); with none verifiable the gate is inert rather than locking the route on a self-hosted install. A denial carries the first verifiable factor's code plus `factors` (the verifiable ones, in the route's order), so a client can lead with one flow and offer the other. `verifiedFactorGate.enabled: false` in config stops every declared gate at once, e.g. during an SMS-provider outage.
|
||||
- **Metered spend:** an endpoint that spends metered resources on the caller's behalf (moving file content, object-store requests, anything else the account is billed for) declares `requireCredits: true`, which turns an account with no budget left away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to see what it has, clear it, and reach its billing pages. Drivers have no route options, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
|
||||
|
||||
Provider sign-in requires the account's authenticator or a recovery code when 2FA is enabled, including signup callbacks that resolve an existing account. The five-minute challenge uses the same single-use `/login/otp` and `/login/recovery-code` verification as password sign-in. The GUI carries it in a URL fragment and removes it before initialization. A session and successful popup-return proof are issued only after verification; cancelling retains the popup's signed retry context without marking it authenticated.
|
||||
|
||||
Disabling 2FA through `/user-protected/disable-2fa` requires both password or OIDC revalidation and the current six-digit authenticator `code`. Missing, malformed, or incorrect codes leave the secret and recovery codes intact. A team's requirement for 2FA still prevents disabling it.
|
||||
|
||||
Email changes through `/user-protected/change-email` require the password or OIDC revalidation cookie, plus a six-digit `code` from the authenticator when 2FA is enabled. Missing or malformed codes return `400 bad_request`; incorrect codes return `401 code_mismatch`. Both include `factor: 'otp'`. Confirmation links are bound to that verified request. A 2FA-enabled account must request a new link if its existing link was issued without second-factor verification.
|
||||
|
||||
+4
-1
@@ -51,7 +51,10 @@ two pages can't be decoded into how fast the table grows; `openCursor` still
|
||||
reads a plain cursor. What a cursor wraps is an implementation detail per
|
||||
store:
|
||||
|
||||
- DynamoDB-backed lists wrap `LastEvaluatedKey`.
|
||||
- DynamoDB-backed lists wrap `LastEvaluatedKey`. A kv listing of another
|
||||
namespace (cross-app or through a share handle) resumes after the last entry
|
||||
it returned instead, since the scan can stop on a private one, and its cursor
|
||||
is sealed to that listing and padded to one length.
|
||||
- SQL-backed lists wrap a keyset position — `(sortValue, id)` of the last
|
||||
row — and the query seeks past it (`WHERE (col, id) > (?, ?) ORDER BY col,
|
||||
id`). Any SQL list gaining a cursor must have a deterministic `ORDER BY`
|
||||
|
||||
+13
-25
@@ -1,13 +1,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { installSeatStub } from '@heyputer/backend/testFixtures/seatStub';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { makeActor, type Actor } from '../src/backend/core/actor.ts';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { configContainer } from '../src/backend/exports.ts';
|
||||
@@ -49,7 +43,6 @@ beforeAll(async () => {
|
||||
create_shortcut: true,
|
||||
payment_bypass: true,
|
||||
},
|
||||
teams_enabled: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
@@ -83,21 +76,20 @@ describe('whoami extension — handleWhoami', () => {
|
||||
};
|
||||
|
||||
const seatOf = async (teamName: string) => {
|
||||
const owner = await seedUser();
|
||||
const seat = await seedSeat();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id as number,
|
||||
const team = {
|
||||
uid: `wt-${Math.random().toString(36).slice(2, 9)}`,
|
||||
name: teamName,
|
||||
handle: `wt-${Math.random().toString(36).slice(2, 9)}`,
|
||||
});
|
||||
await server.stores.team.addMember(team.uid, seat.id as number, {
|
||||
orgOwned: true,
|
||||
};
|
||||
installSeatStub(server as never, [seat.id as number], {
|
||||
teamUid: team.uid,
|
||||
teamName: team.name,
|
||||
});
|
||||
return { seat, team };
|
||||
};
|
||||
|
||||
it('names the team for a seat', async () => {
|
||||
const { seat, team } = await seatOf('Acme Corp');
|
||||
it('names no team where teams is off', async () => {
|
||||
const { seat } = await seatOf('Acme Corp');
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
@@ -105,10 +97,8 @@ describe('whoami extension — handleWhoami', () => {
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
expect((captured.body as { team?: unknown }).team).toEqual({
|
||||
uid: team.uid,
|
||||
name: 'Acme Corp',
|
||||
});
|
||||
// Off, so the seat is never looked up.
|
||||
expect((captured.body as { team?: unknown }).team).toBeUndefined();
|
||||
});
|
||||
|
||||
it('says nothing for an account that is not a seat', async () => {
|
||||
@@ -526,9 +516,7 @@ describe('whoami extension — handleWhoami', () => {
|
||||
}),
|
||||
]) {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor }, () =>
|
||||
handleWhoami(makeReq(), res),
|
||||
);
|
||||
await runWithContext({ actor }, () => handleWhoami(makeReq(), res));
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.phone).toBeUndefined();
|
||||
|
||||
Generated
+92
-80
@@ -1503,6 +1503,17 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dual-bundle/import-meta-resolve": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz",
|
||||
"integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/JounQin"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
@@ -6526,14 +6537,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
|
||||
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz",
|
||||
"integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -6547,8 +6558,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.11",
|
||||
"vitest": "4.1.11"
|
||||
"@vitest/browser": "4.1.12",
|
||||
"vitest": "4.1.12"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -6557,16 +6568,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -6575,13 +6586,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -6602,9 +6613,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
|
||||
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6615,13 +6626,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -6629,14 +6640,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -6645,9 +6656,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -6655,13 +6666,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -7768,9 +7779,9 @@
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -9125,14 +9136,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier": {
|
||||
"version": "5.5.6",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz",
|
||||
"integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==",
|
||||
"version": "5.5.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.7.tgz",
|
||||
"integrity": "sha512-WjHyn3GrFd+ZR0rzsLiM4zJZ35PZMOE0SUIxzGmnWNG0126yNfJKJzObmmkYDwHJfGlkz3PSlzyG9ez/LJntwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prettier-linter-helpers": "^1.0.1",
|
||||
"synckit": "^0.11.13"
|
||||
"synckit": "^0.12.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
@@ -9881,9 +9892,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
|
||||
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -13363,9 +13374,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.24.5",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.5.tgz",
|
||||
"integrity": "sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==",
|
||||
"version": "3.24.6",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.6.tgz",
|
||||
"integrity": "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
@@ -15578,13 +15589,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/smtp-server": {
|
||||
"version": "3.19.21",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.21.tgz",
|
||||
"integrity": "sha512-d0wWijB5dK4gImQidwWMlcGsycqcDCJV0VdQbfz61rgkDqnzAx0Epvi0o5JN2nnr/YwriVC+XV0T8YgcoB0bng==",
|
||||
"version": "3.19.23",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.23.tgz",
|
||||
"integrity": "sha512-t6AOBe7RR9Y/9Kox4e5SIm7wS2ZA8P0VuhVEZ5Yiox0WyevX+rMjATzIwEX3A+B6SGwnJ8QvoHtJAhbJYbjy3A==",
|
||||
"license": "MIT-0",
|
||||
"dependencies": {
|
||||
"ipv6-normalize": "1.0.1",
|
||||
"nodemailer": "10.1.0",
|
||||
"nodemailer": "10.1.2",
|
||||
"punycode.js": "2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -15592,9 +15603,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/smtp-server/node_modules/nodemailer": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.0.tgz",
|
||||
"integrity": "sha512-fXW8AulB25aOTuwfRYV6lqHyfz2VM4rr4SGXH03mE7x5znEUpzL5mu6anARUpZN7744Ziwkmr4AxSLPJsBQspg==",
|
||||
"version": "10.1.2",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.2.tgz",
|
||||
"integrity": "sha512-rnQn9Rzh8RAwcp42MVsIvxbsv6YZ2a2/av+uyJ/LPU1+2QSlrRgFsWWZTKenyRUqkIl8urOI0c4+CEcmFdsvQw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -16003,12 +16014,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/synckit": {
|
||||
"version": "0.11.13",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz",
|
||||
"integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==",
|
||||
"version": "0.12.3",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.12.3.tgz",
|
||||
"integrity": "sha512-U+/AXT6LWsxnj2vz9HeUuPWBNgdWxjyIEjEPdbJARC9txmgPzo2VEdqnFo4hHaNyMPtycyVJrSaQYeQv/PoyyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dual-bundle/import-meta-resolve": "^4.2.1",
|
||||
"@pkgr/core": "^0.3.6"
|
||||
},
|
||||
"engines": {
|
||||
@@ -16607,9 +16619,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.30.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
|
||||
"integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
|
||||
"version": "7.30.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
|
||||
"integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
@@ -16853,19 +16865,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/expect": "4.1.12",
|
||||
"@vitest/mocker": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/runner": "4.1.12",
|
||||
"@vitest/snapshot": "4.1.12",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -16893,12 +16905,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"@vitest/browser-playwright": "4.1.12",
|
||||
"@vitest/browser-preview": "4.1.12",
|
||||
"@vitest/browser-webdriverio": "^4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.12",
|
||||
"@vitest/coverage-v8": "4.1.12",
|
||||
"@vitest/ui": "4.1.12",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
@@ -17704,7 +17716,7 @@
|
||||
"lorem-ipsum": "^2.0.8",
|
||||
"mime-types": "^2.1.35",
|
||||
"murmurhash": "^2.0.1",
|
||||
"mysql2": "^3.24.5",
|
||||
"mysql2": "^3.24.6",
|
||||
"nodemailer": "^9.1.1",
|
||||
"openai": "^6.34.0",
|
||||
"otpauth": "^9.5.2",
|
||||
@@ -17712,12 +17724,12 @@
|
||||
"postal-mime": "^3.0.1",
|
||||
"replicate": "^1.0.0",
|
||||
"sharp": "^0.35.5",
|
||||
"smtp-server": "^3.19.21",
|
||||
"smtp-server": "^3.19.23",
|
||||
"socket.io": "^4.8.4",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"together-ai": "^0.33.0",
|
||||
"ua-parser-js": "^1.0.41",
|
||||
"undici": "^7.29.1",
|
||||
"undici": "^7.30.1",
|
||||
"uuid": "^14.0.3",
|
||||
"validator": "^13.15.35"
|
||||
},
|
||||
@@ -19087,7 +19099,7 @@
|
||||
},
|
||||
"src/puter-js": {
|
||||
"name": "@heyputer/puter.js",
|
||||
"version": "2.6.4",
|
||||
"version": "2.6.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@heyputer/kv.js": "^0.2.1",
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PuterServer } from './server.ts';
|
||||
import { createTestUser, setupTestServer } from './testUtil.ts';
|
||||
|
||||
/**
|
||||
* Teams is optional, so core has to work when neither the service nor the store
|
||||
* is registered, which is how this test server boots.
|
||||
*/
|
||||
describe('core without a teams registration', () => {
|
||||
let server: PuterServer;
|
||||
|
||||
const makeUser = async (): Promise<{ id: number; username: string }> => {
|
||||
const username = `nt_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = (await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
})) as unknown as { id: number };
|
||||
return { id: created.id, username };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
// Without this the allowlist check returns before asking Teams.
|
||||
teams_allowed_email_domains: ['test.local'],
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('has no teams in the registry', () => {
|
||||
expect(server.services.team).toBeUndefined();
|
||||
expect(server.stores.team).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses to boot when the config asks for teams', async () => {
|
||||
await expect(
|
||||
setupTestServer({ teams_enabled: true } as never),
|
||||
).rejects.toThrow(/teams_enabled/);
|
||||
});
|
||||
|
||||
it('deletes an account without asking what it was charged for', async () => {
|
||||
const user = await makeUser();
|
||||
await expect(
|
||||
server.services.userAccount.cascadeDelete(user.id),
|
||||
).resolves.not.toThrow();
|
||||
expect(await server.stores.user.getById(user.id)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('answers the address-claim question signup asks', async () => {
|
||||
const user = await makeUser();
|
||||
const row = (await server.stores.user.getById(user.id))!;
|
||||
// Nothing is a seat, so the holder is returned as-is.
|
||||
await expect(
|
||||
server.services.signup.signupEmailHolder(row.email as string, {
|
||||
releaseSeat: true,
|
||||
}),
|
||||
).resolves.toMatchObject({ id: user.id });
|
||||
});
|
||||
|
||||
it('lists what is shared with an account, folding in no team shares', async () => {
|
||||
const user = await makeUser();
|
||||
await expect(
|
||||
server.services.share.listSharedWithMe({
|
||||
user: { id: user.id },
|
||||
} as never),
|
||||
).resolves.toMatchObject({ items: [] });
|
||||
});
|
||||
|
||||
it('refuses a share aimed at a team, rather than failing', async () => {
|
||||
// A real home directory, so the refusal is the recipient's and not
|
||||
// the entry's.
|
||||
const username = `nt_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const creds = await createTestUser(server, {
|
||||
username,
|
||||
password: 'Nt-Pw-12345!',
|
||||
});
|
||||
const row = (await server.stores.user.getByUsername(username))!;
|
||||
const user = { id: row.id, username };
|
||||
const actor = { user: row } as never;
|
||||
expect(creds).toBeTruthy();
|
||||
// The recipient cannot exist, so this is a 404 and not a crash.
|
||||
await expect(
|
||||
server.services.share.share(actor, {
|
||||
recipient: { teamHandle: 'nobody' },
|
||||
mode: 'read',
|
||||
path: `/${user.username}`,
|
||||
} as never),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('serves the homepage shell with the teams tab off', async () => {
|
||||
const user = await makeUser();
|
||||
const row = (await server.stores.user.getById(user.id))!;
|
||||
let html = '';
|
||||
const res = { send: (out: string) => (html = out) };
|
||||
await server.services.homepage.send(
|
||||
{
|
||||
req: { headers: {}, query: {}, get: () => undefined },
|
||||
res,
|
||||
actor: { user: row },
|
||||
} as never,
|
||||
{ title: 'Puter' } as never,
|
||||
{} as never,
|
||||
);
|
||||
const params = JSON.parse(
|
||||
/gui\((\{.*?\})\);/s.exec(html)![1].replaceAll('\\u003c', '<'),
|
||||
);
|
||||
expect(params.teams_ui).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 90;
|
||||
const CURRENT_SCHEMA_VERSION = 91;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
|
||||
@@ -129,6 +129,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[87, ['0092_share-entry-owner.sql']],
|
||||
[88, ['0093_fsentries-parent-uid-index.sql']],
|
||||
[89, ['0094_deleted-apps.sql']],
|
||||
[90, ['0095_kv-share-handle-levels.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Share levels on `kv_share_handles`. See
|
||||
-- sqlite/0095_kv-share-handle-levels.sql for the rationale. No per-file
|
||||
-- applied-state tracking, so every step is guarded and a replay does nothing.
|
||||
|
||||
CALL _puter_add_col('kv_share_handles', 'revoked_by', '`revoked_by` varchar(8) DEFAULT NULL');
|
||||
CALL _puter_add_col('kv_share_handles', 'label', '`label` varchar(512) DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_indexes;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_kv_share_handle_indexes()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'kv_share_handles'
|
||||
AND INDEX_NAME = 'idx_kv_share_handles_grantee'
|
||||
) THEN
|
||||
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_grantee` (`grantee_user_id`, `id`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'kv_share_handles'
|
||||
AND INDEX_NAME = 'idx_kv_share_handles_app'
|
||||
) THEN
|
||||
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_app` (`app_uid`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_kv_share_handle_indexes();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_indexes;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Share levels on kv_share_handles. See
|
||||
-- sqlite/0095_kv-share-handle-levels.sql for the rationale. Idempotent via
|
||||
-- IF NOT EXISTS; there is no per-file applied-state tracking.
|
||||
|
||||
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS revoked_by VARCHAR(8) DEFAULT NULL;
|
||||
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS label VARCHAR(512) DEFAULT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_grantee
|
||||
ON kv_share_handles (grantee_user_id, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_app
|
||||
ON kv_share_handles (app_uid);
|
||||
@@ -0,0 +1,35 @@
|
||||
-- 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/>.
|
||||
|
||||
-- What share levels need from `kv_share_handles`. The level itself is not a
|
||||
-- column: it is the root of the stored `permission`.
|
||||
--
|
||||
-- - `revoked_by` : `owner`, `holder` or `system` (the namespace app was
|
||||
-- deleted). Null while live, and on rows retired before
|
||||
-- this existed.
|
||||
-- - `label` : optional owner-set text the holder is shown.
|
||||
-- - the grantee index serves the holder's listing and their unshare, and
|
||||
-- the app index retiring a deleted app's handles.
|
||||
|
||||
ALTER TABLE `kv_share_handles` ADD COLUMN `revoked_by` TEXT DEFAULT NULL;
|
||||
ALTER TABLE `kv_share_handles` ADD COLUMN `label` TEXT DEFAULT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_grantee`
|
||||
ON `kv_share_handles` (`grantee_user_id`, `id`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_app`
|
||||
ON `kv_share_handles` (`app_uid`);
|
||||
@@ -188,6 +188,47 @@ describe('DDBClient — item operations', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('puts only while its condition holds', async () => {
|
||||
const absent = {
|
||||
expression: 'attribute_not_exists(#pk)',
|
||||
names: { '#pk': 'pk' },
|
||||
};
|
||||
await client.put(
|
||||
TABLE,
|
||||
{ pk: 'u7', sk: 'once', v: 1 },
|
||||
{
|
||||
condition: absent,
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
client.put(
|
||||
TABLE,
|
||||
{ pk: 'u7', sk: 'once', v: 2 },
|
||||
{
|
||||
condition: absent,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ name: 'ConditionalCheckFailedException' });
|
||||
expect(
|
||||
(await client.get(TABLE, { pk: 'u7', sk: 'once' })).Item?.v,
|
||||
).toBe(1);
|
||||
|
||||
await client.put(
|
||||
TABLE,
|
||||
{ pk: 'u7', sk: 'once', v: 3 },
|
||||
{
|
||||
condition: {
|
||||
expression: '#v = :held',
|
||||
names: { '#v': 'v' },
|
||||
values: { ':held': 1 },
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(
|
||||
(await client.get(TABLE, { pk: 'u7', sk: 'once' })).Item?.v,
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('fetches keys from several tables in one batch', async () => {
|
||||
await client.put(TABLE, { pk: 'b1', sk: 'a', v: 1 });
|
||||
await client.put(TABLE, { pk: 'b1', sk: 'b', v: 2 });
|
||||
@@ -501,6 +542,19 @@ describe('DDBClient — credentialled configuration', () => {
|
||||
).toThrow('requires both `access_key` and `secret_key`');
|
||||
});
|
||||
|
||||
it('accepts the credential chain in place of keys', async () => {
|
||||
const client = new DDBClient({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
dynamo: {
|
||||
aws: { useCredentialChain: true, region: 'us-west-2' },
|
||||
endpoint: 'http://127.0.0.1:1',
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
|
||||
await expect(client.recreateClient()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips table creation on a managed deployment', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const client = new DDBClient({
|
||||
|
||||
@@ -43,6 +43,10 @@ import { once } from 'node:events';
|
||||
import { Agent as httpsAgent } from 'node:https';
|
||||
import { HttpError } from '../../core/http';
|
||||
import type { IConfig, IDynamoConfig } from '../../types';
|
||||
import {
|
||||
awsCredentialsFrom,
|
||||
type AwsCredentials,
|
||||
} from '../../util/awsCredentials.js';
|
||||
import { BoundedTtlMap } from '../../util/boundedTtlMap.js';
|
||||
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
|
||||
import { Span } from '../../util/span.js';
|
||||
@@ -248,7 +252,19 @@ export class DDBClient extends PuterClient {
|
||||
}
|
||||
|
||||
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
|
||||
async put<T extends Record<string, unknown>>(table: string, item: T) {
|
||||
async put<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
item: T,
|
||||
opts?: {
|
||||
/** Refused (`ConditionalCheckFailedException`) unless it holds. */
|
||||
condition?: {
|
||||
expression: string;
|
||||
names?: Record<string, string>;
|
||||
values?: Record<string, unknown>;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const condition = opts?.condition;
|
||||
const client = await this.#getDocumentClient();
|
||||
return sendRepairingValues(
|
||||
item,
|
||||
@@ -258,6 +274,23 @@ export class DDBClient extends PuterClient {
|
||||
TableName: table,
|
||||
Item: itemToWrite,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
...(condition
|
||||
? {
|
||||
ConditionExpression: condition.expression,
|
||||
...(condition.names
|
||||
? {
|
||||
ExpressionAttributeNames:
|
||||
condition.names,
|
||||
}
|
||||
: {}),
|
||||
...(condition.values
|
||||
? {
|
||||
ExpressionAttributeValues:
|
||||
condition.values,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
() => `a put to ${table}`,
|
||||
@@ -688,17 +721,15 @@ export class DDBClient extends PuterClient {
|
||||
}
|
||||
|
||||
#bindAwsClient() {
|
||||
const accessKeyId = this.#ddbConfig.aws?.access_key;
|
||||
const secretAccessKey = this.#ddbConfig.aws?.secret_key;
|
||||
|
||||
if (!accessKeyId || !secretAccessKey) {
|
||||
const credentials = awsCredentialsFrom(this.#ddbConfig.aws);
|
||||
if (!credentials) {
|
||||
throw new Error(
|
||||
'DynamoDB aws config requires both `access_key` and `secret_key`',
|
||||
'DynamoDB aws config requires both `access_key` and `secret_key`, or `useCredentialChain`',
|
||||
);
|
||||
}
|
||||
|
||||
this.#bindClient({
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
credentials,
|
||||
region: this.#ddbConfig.aws?.region || 'us-west-2',
|
||||
endpoint: this.#ddbConfig.endpoint,
|
||||
});
|
||||
@@ -720,7 +751,7 @@ export class DDBClient extends PuterClient {
|
||||
region,
|
||||
endpoint,
|
||||
}: {
|
||||
credentials: { accessKeyId: string; secretAccessKey: string };
|
||||
credentials: AwsCredentials;
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
}) {
|
||||
|
||||
@@ -397,6 +397,8 @@ export type EventMap = {
|
||||
user_id: number;
|
||||
user_uuid?: string;
|
||||
stripe_customer_id?: string | null;
|
||||
/** Apps the account owned, which cascaded away with it. */
|
||||
appUids?: string[];
|
||||
};
|
||||
|
||||
// ---- Team billing ---- the trigger, never the charge ----
|
||||
@@ -490,13 +492,33 @@ export type EventMap = {
|
||||
noShareKeys?: string[];
|
||||
/** Events handler runs behind the write; see `Actor.handlerDepth`. */
|
||||
handlerDepth?: number;
|
||||
/**
|
||||
* The user who made the change. Differs from `userId` when a share
|
||||
* holder wrote into the owner's namespace.
|
||||
*/
|
||||
actingUserId?: number;
|
||||
/** Made by a worker session or an events handler; see `viaWorkerOf`. */
|
||||
viaWorker?: true;
|
||||
};
|
||||
/**
|
||||
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
|
||||
* key enumeration is truncated for a large namespace, so the keys it saw
|
||||
* are not the keys it removed.
|
||||
*/
|
||||
'kv.flushed': { namespace: string; userId: number };
|
||||
'kv.flushed': {
|
||||
namespace: string;
|
||||
userId: number;
|
||||
actingUserId?: number;
|
||||
viaWorker?: true;
|
||||
};
|
||||
/**
|
||||
* Share handles stopped being addresses, whoever retired them. `userId` is
|
||||
* one user who could have subscribed through them, their owner or their
|
||||
* holder. Listeners end what was made through them even where another grant
|
||||
* still covers the region. Local, like `permission.revoked`: one region
|
||||
* settling covers every other.
|
||||
*/
|
||||
'kv.share.retired': { handles: string[]; userId: number };
|
||||
|
||||
// ---- Notifications ----
|
||||
/**
|
||||
@@ -679,6 +701,10 @@ export type EventMap = {
|
||||
requestHash?: string;
|
||||
mime: string;
|
||||
};
|
||||
// Asked for a request whose host is outside every configured domain. A
|
||||
// listener that recognises the host sets `result.subdomain` to the site
|
||||
// it serves; an unclaimed host is answered with a 404.
|
||||
'site.resolveHost': { host: string; result: { subdomain: string | null } };
|
||||
// Asked before a hosted file is streamed. A listener that owns the site
|
||||
// sets `result.allowed = false` to withhold the entry; the visitor then
|
||||
// sees the same 404 as for a missing file.
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
@@ -24,7 +25,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { S3Client } from './S3Client';
|
||||
import { S3Client, withPresignHeadroom } from './S3Client';
|
||||
|
||||
const LOCAL_BUCKET = 'puter-local';
|
||||
|
||||
@@ -487,3 +488,54 @@ describe('S3Client — short reads during multipart', () => {
|
||||
expect(parts).toEqual([10, 10]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withPresignHeadroom', () => {
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const creds = (msLeft?: number, id = 'a') => ({
|
||||
accessKeyId: id,
|
||||
secretAccessKey: 's',
|
||||
...(msLeft === undefined
|
||||
? {}
|
||||
: { expiration: new Date(Date.now() + msLeft) }),
|
||||
});
|
||||
|
||||
it('reports expiry an hour early so the SDK refreshes while an hour remains', async () => {
|
||||
const chain = vi.fn(async () => creds(5 * HOUR));
|
||||
const identity = await withPresignHeadroom(chain)();
|
||||
|
||||
expect(chain).toHaveBeenCalledTimes(1);
|
||||
const left = identity.expiration!.getTime() - Date.now();
|
||||
expect(left).toBeGreaterThan(4 * HOUR - 1000);
|
||||
expect(left).toBeLessThanOrEqual(4 * HOUR);
|
||||
});
|
||||
|
||||
it('force-refreshes credentials that are inside the presign window', async () => {
|
||||
const chain = vi.fn(async (options?: { forceRefresh?: boolean }) =>
|
||||
options?.forceRefresh
|
||||
? creds(6 * HOUR, 'fresh')
|
||||
: creds(30 * 60 * 1000, 'stale'),
|
||||
);
|
||||
const identity = await withPresignHeadroom(chain)();
|
||||
|
||||
expect(chain).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ forceRefresh: true }),
|
||||
);
|
||||
expect(identity.accessKeyId).toBe('fresh');
|
||||
});
|
||||
|
||||
it('leaves long-lived credentials alone', async () => {
|
||||
const chain = vi.fn(async () => creds(undefined));
|
||||
const identity = await withPresignHeadroom(chain)();
|
||||
|
||||
expect(chain).toHaveBeenCalledTimes(1);
|
||||
expect(identity.expiration).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never reports an expiry inside the SDK refresh margin', async () => {
|
||||
const chain = vi.fn(async () => creds(20 * 60 * 1000));
|
||||
const identity = await withPresignHeadroom(chain)();
|
||||
|
||||
const left = identity.expiration!.getTime() - Date.now();
|
||||
expect(left).toBeGreaterThan(5 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,43 @@ const DEFAULT_MULTIPART_PART_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
const FAUXQS_SAFE_PUT_OBJECT_LIMIT_BYTES = 10 * 1024 * 1024;
|
||||
const LEGACY_STORAGE_BUCKET = 'puter-local';
|
||||
|
||||
// A presigned URL stops working when the credentials that signed it expire.
|
||||
// Presigns last up to an hour, but the SDK only refreshes temporary
|
||||
// credentials five minutes before expiry.
|
||||
const PRESIGN_MAX_LIFETIME_MS = 60 * 60 * 1000;
|
||||
const SDK_REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
||||
|
||||
type CredentialChain = ReturnType<typeof fromNodeProviderChain>;
|
||||
|
||||
/**
|
||||
* Keeps temporary credentials at least an hour from expiry when they sign. The
|
||||
* SDK is handed each identity with its expiration moved an hour early, so its
|
||||
* own refresh comes back here while an hour remains, and a credential already
|
||||
* inside that window is force-refreshed from the chain.
|
||||
*/
|
||||
export function withPresignHeadroom(chain: CredentialChain): CredentialChain {
|
||||
return async (options) => {
|
||||
let identity = await chain(options);
|
||||
const remaining = (i: typeof identity) =>
|
||||
i.expiration ? i.expiration.getTime() - Date.now() : Infinity;
|
||||
if (
|
||||
remaining(identity) <
|
||||
PRESIGN_MAX_LIFETIME_MS + SDK_REFRESH_MARGIN_MS
|
||||
) {
|
||||
identity = await chain({ ...options, forceRefresh: true });
|
||||
}
|
||||
if (!identity.expiration) return identity;
|
||||
// If even fresh credentials are short-lived, report an expiry just past
|
||||
// the SDK's margin rather than one in the past, which would make it
|
||||
// re-resolve on every request.
|
||||
const reported = Math.max(
|
||||
identity.expiration.getTime() - PRESIGN_MAX_LIFETIME_MS,
|
||||
Date.now() + SDK_REFRESH_MARGIN_MS + 60 * 1000,
|
||||
);
|
||||
return { ...identity, expiration: new Date(reported) };
|
||||
};
|
||||
}
|
||||
|
||||
type S3CommandSender = Pick<AwsS3Client, 'send'>;
|
||||
|
||||
export class S3Client extends PuterClient {
|
||||
@@ -83,7 +120,9 @@ export class S3Client extends PuterClient {
|
||||
|
||||
if (useCredentialChain) {
|
||||
this.useProviderChain = true;
|
||||
this.awsConfig = { credentials: fromNodeProviderChain() };
|
||||
this.awsConfig = {
|
||||
credentials: withPresignHeadroom(fromNodeProviderChain()),
|
||||
};
|
||||
this.partSize = 64 * 1024 * 1024;
|
||||
this.maxSingleUploadSize = 128 * 1024 * 1024;
|
||||
} else {
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
/**
|
||||
* HTTP-level coverage for `/auth/revoke-own-access-token` — the route that
|
||||
@@ -424,7 +423,7 @@ describe('2FA enrollment over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({ teams_enabled: true } as IConfig);
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -561,56 +560,6 @@ describe('2FA enrollment over HTTP', () => {
|
||||
expect((await otpState(account.userId)).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('lets a seat its team holds for 2FA enroll through the same gate', async () => {
|
||||
const owner = await makeAccount();
|
||||
// The rule can only be turned on by an owner who has 2FA.
|
||||
await env.server.stores.user.update(owner.userId, { otp_enabled: 1 });
|
||||
await env.server.stores.user.invalidateById(owner.userId);
|
||||
const team = await env.server.services.team.createTeam(owner.userId, {
|
||||
name: 'Acme',
|
||||
});
|
||||
const seatName = `seat_${uniq()}`;
|
||||
const { userId, temporaryPassword } =
|
||||
await env.server.services.team.provisionAccount(
|
||||
team.uid,
|
||||
owner.userId,
|
||||
{ username: seatName },
|
||||
);
|
||||
await env.server.services.team.updateTeam(team.uid, owner.userId, {
|
||||
require2fa: true,
|
||||
});
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
(await env.server.stores.user.getById(userId))!,
|
||||
);
|
||||
|
||||
// First sign-in replaces the temporary password.
|
||||
const changed = await protectedPost(
|
||||
'/user-protected/change-password',
|
||||
token,
|
||||
{ password: temporaryPassword, new_pass: 'seat-own-password' },
|
||||
);
|
||||
expect(changed.status).toBe(200);
|
||||
|
||||
const held = await fetch(new URL('/teams', env.apiOrigin), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(held.status).toBe(403);
|
||||
expect(await held.text()).toContain('two_factor_required');
|
||||
|
||||
const res = await setup(token, { password: 'seat-own-password' });
|
||||
expect(res.status).toBe(200);
|
||||
const { secret } = (await res.json()) as { secret: string };
|
||||
const enabled = await configure(token, 'enable', {
|
||||
code: liveTotp(seatName, secret),
|
||||
});
|
||||
expect(enabled.status).toBe(200);
|
||||
expect((await otpState(userId)).enabled).toBe(true);
|
||||
|
||||
const released = await fetch(new URL('/teams', env.apiOrigin), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(released.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
* play along. Aligns with AGENTS.md: "Prefer test server over mocking deps."
|
||||
*/
|
||||
|
||||
import { installSeatStub } from '../../testFixtures/seatStub.js';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { TOTP } from 'otpauth';
|
||||
@@ -48,7 +49,7 @@ import {
|
||||
} from '../../core/http/types.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
|
||||
import { kvSharePermission } from '../../services/events/kvShares.js';
|
||||
import { kvSharePermission } from '../../services/kvShare/kvShares.js';
|
||||
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { resetCardVerificationStatusCache } from '../../util/cardFallback.js';
|
||||
@@ -2150,7 +2151,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = `service:tg-app:ii:read`;
|
||||
const permission = `events:background`;
|
||||
// The controller's grant call delegates through PermissionService
|
||||
// (which uses ALS Context.set), so wrap in runWithContext.
|
||||
const res = makeRes();
|
||||
@@ -2191,7 +2192,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = `service:tg-origin-app:ii:read`;
|
||||
const permission = `events:background`;
|
||||
const res = makeRes();
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
@@ -2228,7 +2229,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = `service:tr-origin-app:ii:read`;
|
||||
const permission = `events:background`;
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
@@ -2293,7 +2294,7 @@ describe('AuthController grant flows', () => {
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleRevokeUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: 'service:x:ii:read' },
|
||||
{ app_uid: app.uid, permission: 'events:background' },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
@@ -2317,12 +2318,12 @@ describe('AuthController grant flows', () => {
|
||||
|
||||
it('grant-user-app: 400 on non-string or oversized origin/app_uid/permission', async () => {
|
||||
const cases = [
|
||||
{ origin: { host: 'evil' }, permission: 'service:x:ii:read' },
|
||||
{ origin: 'https://a.test', permission: ['service:x:ii:read'] },
|
||||
{ app_uid: 12345, permission: 'service:x:ii:read' },
|
||||
{ origin: { host: 'evil' }, permission: 'events:background' },
|
||||
{ origin: 'https://a.test', permission: ['events:background'] },
|
||||
{ app_uid: 12345, permission: 'events:background' },
|
||||
{
|
||||
origin: `https://${'a'.repeat(5000)}.test`,
|
||||
permission: 'service:x:ii:read',
|
||||
permission: 'events:background',
|
||||
},
|
||||
];
|
||||
for (const body of cases) {
|
||||
@@ -2353,7 +2354,7 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: target.id },
|
||||
);
|
||||
|
||||
const permission = 'service:squat:ii:read';
|
||||
const permission = 'events:background';
|
||||
for (const handler of [
|
||||
'handleGrantUserApp',
|
||||
'handleRevokeUserApp',
|
||||
@@ -2397,7 +2398,7 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: target.id },
|
||||
);
|
||||
|
||||
const permission = 'service:squat-beside:ii:read';
|
||||
const permission = 'events:background';
|
||||
for (const handler of [
|
||||
'handleGrantUserApp',
|
||||
'handleRevokeUserApp',
|
||||
@@ -2450,7 +2451,7 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
|
||||
const permission = 'service:tp-origin:ii:read';
|
||||
const permission = 'events:background';
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
@@ -2486,7 +2487,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = 'service:tm-app:ii:read';
|
||||
const permission = 'events:background';
|
||||
for (const body of [
|
||||
{ app_uid: app.uid, permission, meta: 'nope' },
|
||||
{ app_uid: app.uid, permission, meta: [1, 2] },
|
||||
@@ -2527,7 +2528,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = 'service:tb-app:ii:read';
|
||||
const permission = 'events:background';
|
||||
const oversized = { blob: 'z'.repeat(5000) };
|
||||
for (const body of [
|
||||
{ app_uid: app.uid, permission, extra: oversized },
|
||||
@@ -2604,26 +2605,278 @@ describe('AuthController grant flows', () => {
|
||||
expect(res.body).toEqual({});
|
||||
});
|
||||
|
||||
it('grant-user-app: 400 on a `permission` wider than the column it lands in', async () => {
|
||||
// 300 chars is under the 4096 input cap but over `varchar(255)`, so it
|
||||
// used to reach the INSERT and fault on MySQL/Postgres. The check runs
|
||||
// after the rewrite, so this has to be a permission no rewriter
|
||||
// shortens — and it has to reject before the app is resolved, since
|
||||
// this uid names no app.
|
||||
await expect(
|
||||
it('grant-user-app: grants read and write share delegations on a region, never on the namespace', async () => {
|
||||
const appName = `tkvl-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestKvShareLevelsApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
for (const root of ['kv-share-read', 'kv-share-write']) {
|
||||
const namespace = `manage:${root}:${issuer.uuid}:${app.uid}`;
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: namespace },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ legacyCode: 'invalid_kv_share_prefix' });
|
||||
|
||||
const res = makeRes();
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: `${namespace}:team` },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({});
|
||||
}
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT `permission` FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ? ORDER BY `permission`',
|
||||
[issuer.id, app.id],
|
||||
);
|
||||
expect(rows.map((r: { permission: string }) => r.permission)).toEqual([
|
||||
`manage:kv-share-read:${issuer.uuid}:${app.uid}:team`,
|
||||
`manage:kv-share-write:${issuer.uuid}:${app.uid}:team`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('grant-user-app: refuses a share delegation whose region a new share could not name, writing nothing', async () => {
|
||||
const appName = `tkvc-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestKvShareCharsApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
for (const region of ['Team', 'team:Abc', 'caf\u00E9', 'a b'])
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{
|
||||
app_uid: app.uid,
|
||||
permission: `manage:kv-share-read:${issuer.uuid}:${app.uid}:${region}`,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ legacyCode: 'invalid_kv_share_prefix' });
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT `permission` FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?',
|
||||
[issuer.id, app.id],
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('grant-user-app: refuses a permission outside the app-grantable families, writing nothing', async () => {
|
||||
const appName = `tng-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestNotGrantableApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const other = await server.stores.app.create(
|
||||
{
|
||||
name: `tng-other-${uuidv4()}`,
|
||||
title: 'TestNotGrantableOther',
|
||||
index_url: `https://tng-other-${uuidv4()}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const refused = [
|
||||
['driver:puter-image-generation:generate'],
|
||||
[`user:${target.uuid}:email:read`],
|
||||
// One bad entry refuses the batch.
|
||||
['events:background', 'random-string'],
|
||||
// A share delegation names the app's own namespace only.
|
||||
[`manage:kv-share:${issuer.uuid}:${other.uid}:rooms`],
|
||||
];
|
||||
for (const permissions of refused) {
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permissions },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'permission_not_grantable',
|
||||
});
|
||||
}
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT 1 FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?',
|
||||
[issuer.id, app.id],
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('grant-user-app: refuses a non-canonical spelling of a grantable permission', async () => {
|
||||
const appName = `tnc-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestNonCanonicalApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
for (const permission of [
|
||||
`f\\s:/${issuer.username}/Documents:read`,
|
||||
`app-root-di\\r:${app.uid}:read`,
|
||||
`manag\\e:kv-share:${issuer.uuid}:${app.uid}:rooms`,
|
||||
]) {
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ legacyCode: 'permission_not_grantable' });
|
||||
}
|
||||
});
|
||||
|
||||
it('grant-user-app: refuses a share delegation over a prefix no share could be made of, as a share would be', async () => {
|
||||
const appName = `tkvp-${uuidv4().slice(0, 8)}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestKvSharePrefixApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
for (const root of ['kv-share', 'kv-share-read', 'kv-share-write']) {
|
||||
// A share grant one character wider than the column.
|
||||
const head = `${root}:${issuer.uuid}:${app.uid}:`;
|
||||
const region = 'a'.repeat(256 - head.length);
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{
|
||||
app_uid: app.uid,
|
||||
permission: `manage:${head}${region}`,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ legacyCode: 'invalid_kv_share_prefix' });
|
||||
}
|
||||
});
|
||||
|
||||
it('grant-user-app: a share delegation must name the app the request names, by uid, name or origin', async () => {
|
||||
const appName = `tkvn-${uuidv4()}`;
|
||||
const origin = `https://${appName}.example.test`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestKvShareNamedApp',
|
||||
index_url: `${origin}/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const other = await server.stores.app.create(
|
||||
{
|
||||
name: `tkvn-other-${uuidv4()}`,
|
||||
title: 'TestKvShareNamedOther',
|
||||
index_url: `https://tkvn-other-${uuidv4()}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const grant = (requester: Record<string, string>, namespace: string) =>
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{
|
||||
app_uid: `app-${uuidv4()}`,
|
||||
permission: `service:${'a'.repeat(300)}:ii:read`,
|
||||
...requester,
|
||||
permission: `manage:kv-share:${issuer.uuid}:${namespace}:rooms`,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
);
|
||||
|
||||
for (const requester of [{ app_uid: app.name }, { origin }]) {
|
||||
await grant(requester, app.uid);
|
||||
await expect(grant(requester, other.uid)).rejects.toMatchObject({
|
||||
legacyCode: 'permission_not_grantable',
|
||||
});
|
||||
}
|
||||
// The app's name is not its namespace.
|
||||
await expect(
|
||||
grant({ app_uid: app.uid }, app.name),
|
||||
).rejects.toMatchObject({ legacyCode: 'permission_not_grantable' });
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT `permission` FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?',
|
||||
[issuer.id, app.id],
|
||||
);
|
||||
expect(rows.map((r: { permission: string }) => r.permission)).toEqual([
|
||||
`manage:kv-share:${issuer.uuid}:${app.uid}:rooms`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('grant-user-app: 400 on a `permission` wider than the column it lands in', async () => {
|
||||
// Under the 4096 input cap but over `varchar(255)`, so it would reach
|
||||
// the INSERT and fault on MySQL/Postgres. The check runs after the
|
||||
// rewrite, so this has to be a grantable permission no rewriter
|
||||
// shortens.
|
||||
const appName = `tw-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestWidthApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
// The shares it delegates fit, at 255 characters; the consent's own
|
||||
// `manage:` arm takes it past the column.
|
||||
const head = `kv-share:${issuer.uuid}:${app.uid}:`;
|
||||
const permission = `manage:${head}${'a'.repeat(255 - head.length)}`;
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: 'Invalid `permission`',
|
||||
});
|
||||
});
|
||||
|
||||
it('grant-user-app: an oversized permission that a rewriter shortens is accepted', async () => {
|
||||
@@ -2635,6 +2888,8 @@ describe('AuthController grant flows', () => {
|
||||
// on "please try again" for any deeply nested file. Exercised here
|
||||
// through a rewriter of our own, since the real fs rewriter resolves
|
||||
// the path through the fsentry store and this suite has no entries.
|
||||
// It rewrites a share delegation only because that is a grantable
|
||||
// shape no production rewriter touches.
|
||||
const appName = `tlp-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
@@ -2645,13 +2900,15 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
|
||||
const prefix = `tlprw-${uuidv4()}`;
|
||||
const longPermission = `${prefix}:${'a'.repeat(300)}:read`;
|
||||
const shortPermission = `${prefix}:${uuidv4()}:read`;
|
||||
// Over the column only by its `manage:` arm, so the shares it
|
||||
// delegates still fit.
|
||||
const head = `kv-share:${issuer.uuid}:${app.uid}:`;
|
||||
const longPermission = `manage:${head}${'a'.repeat(255 - head.length)}`;
|
||||
const shortPermission = `manage:${head}${uuidv4()}`;
|
||||
expect(longPermission.length).toBeGreaterThan(255);
|
||||
expect(shortPermission.length).toBeLessThanOrEqual(255);
|
||||
server.services.permission.registerRewriter({
|
||||
id: `test-shorten-${prefix}`,
|
||||
id: `test-shorten-${app.uid}`,
|
||||
matches: (permission: string) => permission === longPermission,
|
||||
rewrite: async () => shortPermission,
|
||||
});
|
||||
@@ -2711,7 +2968,8 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
|
||||
const prefix = `conc-${uuidv4()}`;
|
||||
// A grantable shape no production rewriter touches.
|
||||
const prefix = `manage:kv-share:${issuer.uuid}:${app.uid}:conc-${uuidv4()}`;
|
||||
let release: () => void;
|
||||
let entered: () => void;
|
||||
const held = new Promise<void>((r) => {
|
||||
@@ -2739,7 +2997,7 @@ describe('AuthController grant flows', () => {
|
||||
makeReq(
|
||||
{
|
||||
app_uid: app.uid,
|
||||
permission: `${prefix}:${app.uid}:write`,
|
||||
permission: `${prefix}:write`,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
@@ -2780,8 +3038,8 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
|
||||
const prefix = `tardrw-${uuidv4()}`;
|
||||
const pseudoPermission = `${prefix}:${app.uid}:write`;
|
||||
const prefix = `manage:kv-share:${issuer.uuid}:${app.uid}:tardrw-${uuidv4()}`;
|
||||
const pseudoPermission = `${prefix}:write`;
|
||||
const resolvedPermission = `fs:${uuidv4()}:write`;
|
||||
const NOTHING = 'nothing-in-particular';
|
||||
server.services.permission.registerRewriter({
|
||||
@@ -3022,7 +3280,7 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
// phase A's pre-flight passes, phase C's re-check then fails, so the
|
||||
// leaf (and its auto-created parent) already exist by the time the
|
||||
// request unwinds.
|
||||
const prefix = `rbparent-${uuidv4()}`;
|
||||
const prefix = `manage:kv-share:${user.uuid}:${appUid}:rbparent-${uuidv4()}`;
|
||||
let calls = 0;
|
||||
server.services.permission.registerRewriter({
|
||||
id: prefix,
|
||||
@@ -3188,13 +3446,13 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
|
||||
});
|
||||
|
||||
it('does not apply `create` to a `manage:` grant — 404 unchanged, nothing created', async () => {
|
||||
it('refuses a `manage:` grant outright, creating nothing', async () => {
|
||||
const p = path(`manage-missing-${rand()}`);
|
||||
await expect(
|
||||
grant({ permission: `manage:fs:${p}:write` }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
statusCode: 400,
|
||||
legacyCode: 'permission_not_grantable',
|
||||
});
|
||||
expect(await server.stores.fsEntry.getEntryByPath(p)).toBeFalsy();
|
||||
});
|
||||
@@ -3284,7 +3542,7 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
// (so nothing is rejected before anything is created) but has grown
|
||||
// too wide by the time phase C re-runs it — modelling a grant that
|
||||
// only fails once the fs path from the same batch already exists.
|
||||
const prefix = `rbtest-${uuidv4()}`;
|
||||
const prefix = `manage:kv-share:${user.uuid}:${appUid}:rbtest-${uuidv4()}`;
|
||||
let calls = 0;
|
||||
server.services.permission.registerRewriter({
|
||||
id: `test-rollback-${prefix}`,
|
||||
@@ -3325,6 +3583,37 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('check-permissions answers an app asking about itself through ACL, so a folder grant covers what is inside', async () => {
|
||||
const dir = path(`acl-self-${rand()}`);
|
||||
const child = `${dir}/inner`;
|
||||
await server.services.fs.mkdir(user.id, {
|
||||
path: child,
|
||||
createMissingParents: true,
|
||||
});
|
||||
expect(await checkAsApp(`fs:${child}:read`)).toBe(false);
|
||||
|
||||
await grant({ permission: `fs:${dir}:write`, create: false });
|
||||
expect(await checkAsApp(`fs:${child}:read`)).toBe(true);
|
||||
expect(await checkAsApp(`fs:${child}:write`)).toBe(true);
|
||||
// Another user's home stays out of reach.
|
||||
expect(await checkAsApp(`fs:/${target.username}/Documents:read`)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('check-permissions answers an app asking about itself through ACL for a uid too', async () => {
|
||||
const dir = path(`acl-self-uid-${rand()}`);
|
||||
const child = await server.services.fs.mkdir(user.id, {
|
||||
path: `${dir}/inner`,
|
||||
createMissingParents: true,
|
||||
});
|
||||
expect(await checkAsApp(`fs:${child.uuid}:read`)).toBe(false);
|
||||
|
||||
await grant({ permission: `fs:${dir}:read`, create: false });
|
||||
expect(await checkAsApp(`fs:${child.uuid}:read`)).toBe(true);
|
||||
expect(await checkAsApp(`fs:${child.uuid}:write`)).toBe(false);
|
||||
});
|
||||
|
||||
it('check-permissions on a missing fs path answers false rather than 404ing, without poisoning a mixed batch', async () => {
|
||||
const missingPath = path(`.checkmissing-${rand()}`);
|
||||
// A held fs grant: the owning user holds it implicitly (is-owner), so
|
||||
@@ -4151,9 +4440,9 @@ describe('AuthController.handleCreateAccessToken + handleRevokeAccessToken', ()
|
||||
['an oversized permission string', ['x'.repeat(4097)]],
|
||||
[
|
||||
'an oversized `extra`',
|
||||
[['service:x:ii:read', { a: 'y'.repeat(5000) }]],
|
||||
[['events:background', { a: 'y'.repeat(5000) }]],
|
||||
],
|
||||
['a non-object `extra`', [['service:x:ii:read', 'nope']]],
|
||||
['a non-object `extra`', [['events:background', 'nope']]],
|
||||
['an empty permission string', ['']],
|
||||
])('rejects %s with 400', async (_label, permissions) => {
|
||||
await expect(
|
||||
@@ -6314,29 +6603,25 @@ describe('AuthController password recovery', () => {
|
||||
it('send-pass-recovery-email: refuses a team seat, and writes no token', async () => {
|
||||
// The seat's address is admin-supplied and unverified; recovery there
|
||||
// would be a takeover channel. Its recovery is the admin's reset.
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
const res = makeRes();
|
||||
await controller.handleSendPassRecoveryEmail(
|
||||
makeReq({ username: seat.username }),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { message: string }).message).toMatch(
|
||||
/If that account exists/i,
|
||||
);
|
||||
const after = await server.stores.user.getById(seat.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.pass_recovery_token).toBeFalsy();
|
||||
const restore = installSeatStub(server as never, [seat.id]);
|
||||
try {
|
||||
const res = makeRes();
|
||||
await controller.handleSendPassRecoveryEmail(
|
||||
makeReq({ username: seat.username }),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { message: string }).message).toMatch(
|
||||
/If that account exists/i,
|
||||
);
|
||||
const after = await server.stores.user.getById(seat.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.pass_recovery_token).toBeFalsy();
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// `system` is seeded by migration; `admin` only by the self-hosted boot.
|
||||
@@ -6725,16 +7010,9 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
});
|
||||
|
||||
it('change-email: 403 for an account its team provisioned', async () => {
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat, actor } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
installSeatStub(server as never, [seat.id]);
|
||||
|
||||
await expect(
|
||||
controller.handleChangeEmail(
|
||||
@@ -6750,17 +7028,9 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
it('change-username: 403 for an account its team provisioned', async () => {
|
||||
// The console lists members by username and the audit log records them
|
||||
// by it; a self-service rename would desync both.
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat, actor } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
// addMember refuses to adopt an account that has one.
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
installSeatStub(server as never, [seat.id]);
|
||||
|
||||
await expect(
|
||||
controller.handleChangeUsername(
|
||||
@@ -7383,6 +7653,88 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
|
||||
]);
|
||||
});
|
||||
|
||||
it('check-permissions: answers false for share grants, which name what a holder is never told, but not for their manage arm', async () => {
|
||||
const { user: owner, actor: ownerActor } = await makeUserAndActor();
|
||||
const { user: holder, actor: holderActor } = await makeUserAndActor();
|
||||
const region = `notes-${uuidv4().slice(0, 8)}`;
|
||||
const grant = `kv-share-read:${owner.uuid}:os-global:${region}`;
|
||||
await inCtx(ownerActor, () =>
|
||||
server.services.permission.grantUserUserPermission(
|
||||
ownerActor,
|
||||
holder.username,
|
||||
grant,
|
||||
),
|
||||
);
|
||||
expect(await server.services.permission.check(holderActor, grant)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const asked = [
|
||||
grant,
|
||||
`kv-share:${owner.uuid}:os-global:${region}:inner`,
|
||||
`kv-share-write:${owner.uuid}:os-global:${region}`,
|
||||
];
|
||||
const res = makeRes();
|
||||
await inCtx(holderActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq({ permissions: asked }, { actor: holderActor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
permissions: Object.fromEntries(asked.map((p) => [p, false])),
|
||||
});
|
||||
|
||||
// What `puter.perms.check('kvShare', …)` asks.
|
||||
const manage = `manage:kv-share-read:${owner.uuid}:os-global:${region}`;
|
||||
const ownerRes = makeRes();
|
||||
await inCtx(ownerActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq({ permissions: [manage] }, { actor: ownerActor }),
|
||||
ownerRes,
|
||||
),
|
||||
);
|
||||
expect(ownerRes.body).toEqual({ permissions: { [manage]: true } });
|
||||
});
|
||||
|
||||
it('check-permissions: answers false for share grants asked by an app actor too', async () => {
|
||||
const { user: owner, actor: ownerActor } = await makeUserAndActor();
|
||||
const { user: holder, actor: holderActor } = await makeUserAndActor();
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `cpk-${uuidv4()}`,
|
||||
title: 'TestCheckPermsKvApp',
|
||||
index_url: 'https://check-perms-kv.example.test/index.html',
|
||||
},
|
||||
{ ownerUserId: holder.id },
|
||||
);
|
||||
const grant = `kv-share-read:${owner.uuid}:${app.uid}:notes-${uuidv4().slice(0, 8)}`;
|
||||
await inCtx(ownerActor, () =>
|
||||
server.services.permission.grantUserUserPermission(
|
||||
ownerActor,
|
||||
holder.username,
|
||||
grant,
|
||||
),
|
||||
);
|
||||
const appActor = makeActor({
|
||||
user: holderActor.user,
|
||||
app: { id: app.id, uid: app.uid },
|
||||
});
|
||||
// The holder's app in the share's namespace holds it.
|
||||
expect(await server.services.permission.check(appActor, grant)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const res = makeRes();
|
||||
await inCtx(appActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq({ permissions: [grant] }, { actor: appActor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({ permissions: { [grant]: false } });
|
||||
});
|
||||
|
||||
// What lets a permission request settle without a prompt. The second string
|
||||
// is a consent scope nothing implies, so the first's `true` is the grant.
|
||||
it('check-permissions: an app-under-user actor sees a grant made to that app', async () => {
|
||||
@@ -7597,7 +7949,7 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const permission = 'service:tl-app:ii:read';
|
||||
const permission = 'events:background';
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq({ app_uid: app.uid, permission, extra: {} }, { actor }),
|
||||
@@ -8154,15 +8506,10 @@ describe('AuthController 2FA flows', () => {
|
||||
it('lets a team seat enable 2FA without a confirmed email', async () => {
|
||||
// A seat has no address by design (PUT-1792) while its team can
|
||||
// require 2FA, so this check would bar it forever.
|
||||
const owner = await makeUserAndActor();
|
||||
const team = await server.services.team.createTeam(owner.user.id, {
|
||||
name: 'Acme',
|
||||
});
|
||||
const seatName = `s_${uniq()}`;
|
||||
await server.services.team.provisionAccount(team.uid, owner.user.id, {
|
||||
username: seatName,
|
||||
});
|
||||
const seat = await server.stores.user.getByUsername(seatName);
|
||||
const { user: made } = await makeUserAndActor({ email_confirmed: 0 });
|
||||
await server.stores.user.update(made.id, { password: null });
|
||||
const seat = await server.stores.user.getById(made.id, { force: true });
|
||||
installSeatStub(server as never, [seat!.id]);
|
||||
const actor = {
|
||||
user: {
|
||||
id: seat!.id,
|
||||
@@ -8183,15 +8530,10 @@ describe('AuthController 2FA flows', () => {
|
||||
});
|
||||
|
||||
it('refuses to disable 2FA while the team requires it', async () => {
|
||||
const owner = await makeUserAndActor({ otp_enabled: 1 });
|
||||
const team = await server.services.team.createTeam(owner.user.id, {
|
||||
name: 'Acme',
|
||||
});
|
||||
const seatName = `s_${uniq()}`;
|
||||
await server.services.team.provisionAccount(team.uid, owner.user.id, {
|
||||
username: seatName,
|
||||
});
|
||||
const seat = await server.stores.user.getByUsername(seatName);
|
||||
const { user: made } = await makeUserAndActor();
|
||||
await server.stores.user.update(made.id, { password: null });
|
||||
const seat = await server.stores.user.getById(made.id, { force: true });
|
||||
installSeatStub(server as never, [seat!.id], { require2fa: true });
|
||||
const actor = {
|
||||
user: {
|
||||
id: seat!.id,
|
||||
@@ -8202,9 +8544,6 @@ describe('AuthController 2FA flows', () => {
|
||||
} as Actor;
|
||||
const { secret } = await setup2fa(actor);
|
||||
await enable2fa(actor, { code: liveTotp(seat!.username, secret) });
|
||||
await server.services.team.updateTeam(team.uid, owner.user.id, {
|
||||
require2fa: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.handleDisable2fa(makeReq({}, { actor }), makeRes()),
|
||||
@@ -8495,16 +8834,9 @@ describe('AuthController.handleDeleteOwnUser', () => {
|
||||
it('refuses, and keeps the row, for an account its team provisioned', async () => {
|
||||
// The team is billed for the seat and closing it is theirs to do, from
|
||||
// the console that keeps the audit trail.
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat, actor } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
installSeatStub(server as never, [seat.id]);
|
||||
|
||||
await expect(
|
||||
controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()),
|
||||
@@ -10253,7 +10585,7 @@ describe('AuthController — app-data grants', () => {
|
||||
await expect(
|
||||
post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permission: 'app-data:app-nope/:kv:read',
|
||||
permission: `app-data:app-${uuidv4()}:kv:read`,
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
@@ -10320,7 +10652,7 @@ describe('AuthController — app-data grants', () => {
|
||||
|
||||
it('leaves unrelated permissions untouched by the new validation', async () => {
|
||||
const grantee = await makeAppRow();
|
||||
const permission = 'service:unrelated:ii:read';
|
||||
const permission = 'events:background';
|
||||
await post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permission,
|
||||
@@ -10334,17 +10666,9 @@ describe('AuthController — app-data grants', () => {
|
||||
describe('AuthController — a seat address nobody confirmed', () => {
|
||||
/** A seat whose address its team typed in; unconfirmed. */
|
||||
const makeSeat = async () => {
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
// A seat is created password-less and given a temporary one after.
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
installSeatStub(server as never, [seat.id]);
|
||||
await server.stores.user.update(seat.id, {
|
||||
password: await bcrypt.hash('temporary-password', 8),
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
hasVerifiedPhone,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { isPlainUserActor, makeActor } from '../../core/actor.js';
|
||||
import { isAppActor, isPlainUserActor, makeActor } from '../../core/actor.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
consumeRouteRateLimit,
|
||||
@@ -88,13 +88,16 @@ import {
|
||||
import { promoteToVerifiedGroup } from '../../util/userProvisioning.js';
|
||||
import {
|
||||
assertBoundedManageGrant,
|
||||
assertNewShareSegments,
|
||||
assertPrefixFitsAccess,
|
||||
isKvSharePermission,
|
||||
} from '../../services/events/kvShares.js';
|
||||
} from '../../services/kvShare/kvShares.js';
|
||||
import {
|
||||
APP_DATA_PERMISSION_PREFIX,
|
||||
appDataSharingAllowed,
|
||||
parseAppDataPermission,
|
||||
} from '../../services/permission/appDataScopes.js';
|
||||
import { parseAppGrantablePermission } from '../../services/permission/appGrantable.js';
|
||||
import {
|
||||
assertCreatablePath,
|
||||
isOwnHomePath,
|
||||
@@ -111,6 +114,7 @@ import { normalizeAbsolutePath } from '../../services/fs/resolveNode.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { startWebSession } from './webSession.js';
|
||||
import { completeOidcLoginReturn } from './oidcLoginReturn.js';
|
||||
|
||||
const FINGERPRINT_MAX_LENGTH = 128;
|
||||
// One consent prompt covers a handful of scopes at most. The cap keeps a
|
||||
@@ -595,6 +599,7 @@ export class AuthController extends PuterController {
|
||||
user_uid: string;
|
||||
purpose: string;
|
||||
auth_id?: string;
|
||||
oidcContinuation?: unknown;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid token.', {
|
||||
@@ -628,7 +633,7 @@ export class AuthController extends PuterController {
|
||||
|
||||
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
|
||||
|
||||
await this.#completeLogin(req, res, user);
|
||||
await this.#completeLogin(req, res, user, decoded.oidcContinuation);
|
||||
}
|
||||
|
||||
// -- Login: recovery code ----------------------------------------
|
||||
@@ -664,6 +669,7 @@ export class AuthController extends PuterController {
|
||||
user_uid: string;
|
||||
purpose: string;
|
||||
auth_id?: string;
|
||||
oidcContinuation?: unknown;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid token.', {
|
||||
@@ -688,6 +694,11 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
await this.#assertSecondFactorAttemptsRemain(user.uuid);
|
||||
if (typeof code !== 'string') {
|
||||
throw new HttpError(400, 'code must be a string.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const hashed = hashRecoveryCode(code);
|
||||
const codes = ((user.otp_recovery_codes as string) || '')
|
||||
.split(',')
|
||||
@@ -700,15 +711,15 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
await this.#consumeOtpLoginToken(token);
|
||||
|
||||
// Consume the recovery code
|
||||
codes.splice(idx, 1);
|
||||
await this.stores.user.update(user.id, {
|
||||
otp_recovery_codes: codes.join(','),
|
||||
});
|
||||
if (!(await this.stores.user.consumeOtpRecoveryCode(user.id, hashed))) {
|
||||
await this.#recordSecondFactorFailure(user.uuid);
|
||||
res.json({ proceed: false });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
|
||||
|
||||
await this.#completeLogin(req, res, user);
|
||||
await this.#completeLogin(req, res, user, decoded.oidcContinuation);
|
||||
}
|
||||
|
||||
// -- Signup ------------------------------------------------------
|
||||
@@ -1932,7 +1943,7 @@ export class AuthController extends PuterController {
|
||||
// A seat's address is admin-supplied and never verified, so whoever
|
||||
// holds that inbox could take the seat over. Its recovery channel is
|
||||
// the team admin's password reset, not this one.
|
||||
if (await this.stores.team.getOrgSeat(user.id)) {
|
||||
if (await this.stores.team?.getOrgSeat(user.id)) {
|
||||
res.json({ message: genericMessage });
|
||||
return;
|
||||
}
|
||||
@@ -2166,8 +2177,8 @@ export class AuthController extends PuterController {
|
||||
// Best effort: the password is already committed, and an audit write
|
||||
// must not skip the eviction below.
|
||||
await this.services.team
|
||||
.recordPasswordSelfChange(user.id as number)
|
||||
.catch((err: unknown) => {
|
||||
?.recordPasswordSelfChange(user.id as number)
|
||||
?.catch((err: unknown) => {
|
||||
console.warn('[team] password self-change audit failed:', err);
|
||||
});
|
||||
|
||||
@@ -2222,8 +2233,8 @@ export class AuthController extends PuterController {
|
||||
temp_password_expires_at: null,
|
||||
});
|
||||
await this.services.team
|
||||
.recordPasswordSelfChange(user.id)
|
||||
.catch((err: unknown) => {
|
||||
?.recordPasswordSelfChange(user.id)
|
||||
?.catch((err: unknown) => {
|
||||
console.warn('[team] password self-change audit failed:', err);
|
||||
});
|
||||
|
||||
@@ -2257,7 +2268,7 @@ export class AuthController extends PuterController {
|
||||
// A provisioned account's name belongs to the team that made it: the
|
||||
// console lists its members by username and the audit log records them
|
||||
// by username, so a self-service rename would desync both.
|
||||
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
|
||||
if (await this.stores.team?.getOrgSeat(req.actor!.user.id!)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team set this username. Ask a team admin to change it.',
|
||||
@@ -2391,7 +2402,7 @@ export class AuthController extends PuterController {
|
||||
async handleChangeEmail(req: Request, res: Response): Promise<void> {
|
||||
// The address is where admin-issued credentials and team notices go;
|
||||
// same reasoning as the username and deletion guards above.
|
||||
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
|
||||
if (await this.stores.team?.getOrgSeat(req.actor!.user.id!)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team set this address. Ask a team admin to change it.',
|
||||
@@ -2989,6 +3000,45 @@ export class AuthController extends PuterController {
|
||||
return typeof permission === 'string' && permission ? [permission] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses any entry outside the families an app can be granted. Anything
|
||||
* else is a row the backend never checks for an app, or one wider than the
|
||||
* consent prompt can describe.
|
||||
*/
|
||||
async #assertAppGrantable(
|
||||
actor: Actor,
|
||||
appIdentifier: string,
|
||||
list: string[],
|
||||
): Promise<void> {
|
||||
const refuse = (entry: string) =>
|
||||
new HttpError(
|
||||
400,
|
||||
`Permission cannot be granted to an app: ${entry}`,
|
||||
{ legacyCode: 'permission_not_grantable' },
|
||||
);
|
||||
let appUid: string | undefined;
|
||||
for (const entry of list) {
|
||||
// A delegation over a whole key-value namespace has no bounded
|
||||
// description; refused with its own code.
|
||||
assertBoundedManageGrant(entry);
|
||||
const parsed = parseAppGrantablePermission(entry, actor.user!.uuid);
|
||||
if (!parsed) throw refuse(entry);
|
||||
if (parsed.family !== 'kvShare') continue;
|
||||
// Held to what a new share's prefix may be, so a consent never
|
||||
// covers shares that could not be made.
|
||||
assertNewShareSegments(parsed.segments);
|
||||
assertPrefixFitsAccess(
|
||||
parsed.ownerUuid,
|
||||
parsed.appUid,
|
||||
parsed.segments.join(':'),
|
||||
parsed.level,
|
||||
);
|
||||
// An app shares regions of its own namespace only.
|
||||
appUid ??= (await this.stores.app.resolveApp(appIdentifier))?.uid;
|
||||
if (parsed.appUid !== appUid) throw refuse(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a cross-app data grant: the target must exist, must not have opted
|
||||
* out of sharing, and must be named. Also creates the target's AppData
|
||||
@@ -3167,6 +3217,7 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
// 400 on a bad value, before anything else runs. Absent means create.
|
||||
const create = parseCreateFlag(req.body?.create);
|
||||
await this.#assertAppGrantable(req.actor!, app_uid, list);
|
||||
|
||||
// Validate every entry before writing or creating anything, so a bad
|
||||
// one in the list cannot leave a partially-granted set behind: the
|
||||
@@ -3179,10 +3230,6 @@ export class AuthController extends PuterController {
|
||||
kind: FsCreateKind;
|
||||
}> = [];
|
||||
for (const entry of list) {
|
||||
// A delegation over a whole key-value namespace has no bounded
|
||||
// description, so it is refused where it is asked for rather than
|
||||
// prompted for and then refused at use.
|
||||
assertBoundedManageGrant(entry);
|
||||
await this.#prepareAppDataGrant(req.actor!, entry);
|
||||
const plan = await this.#planFsPathCreate(
|
||||
req.actor!,
|
||||
@@ -3389,11 +3436,28 @@ export class AuthController extends PuterController {
|
||||
const unique = [...new Set(permissions)] as string[];
|
||||
const result: Record<string, boolean> = {};
|
||||
|
||||
// `fs:` reaches down a tree, and only ACL walks that.
|
||||
// A share grant names its owner and prefix, which its holder is never
|
||||
// told, so asking about one would confirm a guess. `manage:` arms are
|
||||
// the app's own consent and stay answerable.
|
||||
const answerable: string[] = [];
|
||||
for (const permission of unique) {
|
||||
if (
|
||||
typeof permission === 'string' &&
|
||||
isKvSharePermission(permission)
|
||||
)
|
||||
result[permission] = false;
|
||||
else answerable.push(permission);
|
||||
}
|
||||
|
||||
// `fs:` reaches down a tree, and only ACL walks that. An app asking
|
||||
// about itself gets the same answer, so a grant on a parent folder
|
||||
// doesn't read as missing and prompt again.
|
||||
const viaAcl = new Set(
|
||||
app_uid ? unique.filter((p) => aclAnswerableFsPermission(p)) : [],
|
||||
app_uid || isAppActor(actor)
|
||||
? answerable.filter((p) => aclAnswerableFsPermission(p))
|
||||
: [],
|
||||
);
|
||||
const viaScan = unique.filter((p) => !viaAcl.has(p));
|
||||
const viaScan = answerable.filter((p) => !viaAcl.has(p));
|
||||
|
||||
let granted: Map<string, boolean>;
|
||||
try {
|
||||
@@ -4122,7 +4186,7 @@ export class AuthController extends PuterController {
|
||||
|
||||
if (action === 'enable') {
|
||||
// A seat has no email by design, so this would bar it forever.
|
||||
const seat = await this.stores.team.getOrgSeat(user.id);
|
||||
const seat = await this.stores.team?.getOrgSeat(user.id);
|
||||
if (!user.email_confirmed && !seat) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
@@ -4196,7 +4260,7 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
|
||||
const seat = await this.stores.team.getOrgSeat(user.id);
|
||||
const seat = await this.stores.team?.getOrgSeat(user.id);
|
||||
if (seat && Number(seat.require_2fa) === 1) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
@@ -4449,7 +4513,7 @@ export class AuthController extends PuterController {
|
||||
const userId = req.actor!.user.id!;
|
||||
// The team owns the account and is billed for it; only they may close
|
||||
// it, through the console that keeps the audit trail.
|
||||
if (await this.stores.team.getOrgSeat(userId)) {
|
||||
if (await this.stores.team?.getOrgSeat(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team owns this account. Ask a team admin to remove it.',
|
||||
@@ -4459,7 +4523,7 @@ export class AuthController extends PuterController {
|
||||
// The other direction: deleting an owner sets `owner_user_id` NULL and
|
||||
// cascades their membership, leaving a team no one can administer —
|
||||
// not even an admin, since every team route resolves by membership.
|
||||
if (await this.stores.team.countOwned(userId)) {
|
||||
if (await this.stores.team?.countOwned(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Delete the teams you own before closing this account.',
|
||||
@@ -4468,7 +4532,7 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
// A deleted team's seats are suspended, not gone, and only its owner
|
||||
// can retire them.
|
||||
if (await this.stores.team.countOwnedTeamsWithAccounts(userId)) {
|
||||
if (await this.stores.team?.countOwnedTeamsWithAccounts(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Close the accounts your teams created before closing this one.',
|
||||
@@ -4621,7 +4685,15 @@ export class AuthController extends PuterController {
|
||||
req: Request,
|
||||
res: Response,
|
||||
user: UserRow,
|
||||
oidcContinuation?: unknown,
|
||||
): Promise<void> {
|
||||
const redirectUrl =
|
||||
oidcContinuation === undefined
|
||||
? undefined
|
||||
: completeOidcLoginReturn(res, user, oidcContinuation, {
|
||||
config: this.config,
|
||||
oidc: this.services.oidc,
|
||||
});
|
||||
const gui_token = await startWebSession(req, res, user, {
|
||||
config: this.config,
|
||||
auth: this.services.auth,
|
||||
@@ -4647,6 +4719,7 @@ export class AuthController extends PuterController {
|
||||
next_step: 'complete',
|
||||
token: gui_token,
|
||||
user: details,
|
||||
...(redirectUrl ? { redirectUrl } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { installSeatStub } from '../../testFixtures/seatStub.js';
|
||||
|
||||
describe('account change second-factor verification', () => {
|
||||
let env: PuterTestEnv;
|
||||
@@ -14,7 +14,7 @@ describe('account change second-factor verification', () => {
|
||||
const links = new Map<string, string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({ teams_enabled: true } as IConfig);
|
||||
env = await setupPuterTestEnv();
|
||||
vi.spyOn(env.server.clients.email, 'send').mockImplementation(
|
||||
async (to, template, values) => {
|
||||
if (template === 'email_verification_link')
|
||||
@@ -333,15 +333,8 @@ describe('account change second-factor verification', () => {
|
||||
});
|
||||
|
||||
it('does not let a valid code override a team requirement for 2FA', async () => {
|
||||
const owner = await account();
|
||||
const member = await account(true, true);
|
||||
const team = await env.server.services.team.createTeam(owner.id, {
|
||||
name: 'Required 2FA',
|
||||
});
|
||||
await env.server.stores.team.addMember(team.uid, member.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
await env.server.services.team.updateTeam(team.uid, owner.id, {
|
||||
installSeatStub(env.server as never, [member.id], {
|
||||
require2fa: true,
|
||||
});
|
||||
const proof = env.server.services.token.sign('oidc-state', {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { Response } from 'express';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { OIDCService } from '../../services/auth/OIDCService.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
|
||||
export const OIDC_POPUP_RETURN_COOKIE_NAME = 'puter_oidc_popup_return';
|
||||
export const OIDC_POPUP_RETURN_EXPIRY_SEC = 300;
|
||||
|
||||
export interface OidcLoginContinuation {
|
||||
userUuid: string;
|
||||
redirectUri: string;
|
||||
embeddedInPopup: boolean;
|
||||
openerOrigin: string | null;
|
||||
messageId: string | null;
|
||||
switchedToLogin: boolean;
|
||||
}
|
||||
|
||||
interface OidcReturnDeps {
|
||||
config: IConfig;
|
||||
oidc: OIDCService;
|
||||
}
|
||||
|
||||
/** Bind an attested popup return to the browser that completed sign-in. */
|
||||
export function mintOidcPopupReturn(
|
||||
res: Response,
|
||||
payload: Record<string, unknown>,
|
||||
deps: OidcReturnDeps,
|
||||
): string {
|
||||
const nonce = crypto.randomBytes(32).toString('base64url');
|
||||
res.cookie(OIDC_POPUP_RETURN_COOKIE_NAME, nonce, {
|
||||
...sessionCookieFlags(deps.config, { crossSite: false }),
|
||||
httpOnly: true,
|
||||
maxAge: OIDC_POPUP_RETURN_EXPIRY_SEC * 1000,
|
||||
path: '/',
|
||||
});
|
||||
return deps.oidc.signPopupReturn({ action: 'sign-in', ...payload, nonce });
|
||||
}
|
||||
|
||||
/** Call only after both the provider and the account's second factor succeed. */
|
||||
export function completeOidcLoginReturn(
|
||||
res: Response,
|
||||
user: Pick<UserRow, 'uuid'>,
|
||||
continuation: unknown,
|
||||
deps: OidcReturnDeps,
|
||||
): string {
|
||||
if (!continuation || typeof continuation !== 'object') {
|
||||
throw new HttpError(400, 'Invalid login continuation.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const data = continuation as OidcLoginContinuation;
|
||||
if (
|
||||
data.userUuid !== user.uuid ||
|
||||
typeof data.redirectUri !== 'string' ||
|
||||
typeof data.embeddedInPopup !== 'boolean' ||
|
||||
typeof data.switchedToLogin !== 'boolean' ||
|
||||
(data.openerOrigin !== null && typeof data.openerOrigin !== 'string') ||
|
||||
(data.messageId !== null && typeof data.messageId !== 'string')
|
||||
) {
|
||||
throw new HttpError(400, 'Invalid login continuation.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const origin = (deps.config.origin ?? '').replace(/\/$/, '');
|
||||
let target = data.redirectUri || origin || '/';
|
||||
try {
|
||||
if (origin && new URL(target).origin !== new URL(origin).origin)
|
||||
target = origin;
|
||||
} catch {
|
||||
target = origin || '/';
|
||||
}
|
||||
const append = (key: string, value: string) => {
|
||||
target += `${target.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
};
|
||||
if (data.embeddedInPopup) {
|
||||
append('oidc_login', 'true');
|
||||
append(
|
||||
'opener_state',
|
||||
mintOidcPopupReturn(
|
||||
res,
|
||||
{
|
||||
opener_origin: data.openerOrigin,
|
||||
msg_id: data.messageId,
|
||||
oidc_login: true,
|
||||
user_uuid: user.uuid,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.switchedToLogin) append('oidc_switched', 'login');
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
describe('recovery code consumption', () => {
|
||||
let env: PuterTestEnv;
|
||||
const password = 'recovery-code-password';
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Hold the same cached snapshot until every concurrent request has read it.
|
||||
const overlapReads = (userUuid: string, count: number) => {
|
||||
const redis = env.server.clients.redis;
|
||||
const get = redis.get.bind(redis);
|
||||
const ready = Promise.withResolvers<void>();
|
||||
let reads = 0;
|
||||
vi.spyOn(redis, 'get').mockImplementation(async (key: string) => {
|
||||
const value = await get(key);
|
||||
if (key.includes(userUuid) && reads < count) {
|
||||
if (++reads === count) ready.resolve();
|
||||
await ready.promise;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
};
|
||||
|
||||
const post = async (path: string, body: Record<string, unknown>) => {
|
||||
const response = await fetch(new URL(path, env.origin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { response, body: await response.json() };
|
||||
};
|
||||
|
||||
const account = async (codes: string[]) => {
|
||||
const username = `recovery_${uuidv4().slice(0, 8)}`;
|
||||
await createTestUser(env.server, { username, password });
|
||||
const user = (await env.server.stores.user.getByUsername(username))!;
|
||||
await env.server.stores.user.update(user.id, {
|
||||
otp_enabled: true,
|
||||
otp_secret: 'TESTSECRETBASE32',
|
||||
otp_recovery_codes: codes.map(hashRecoveryCode).join(','),
|
||||
});
|
||||
const challenge = async () => {
|
||||
const { response, body } = await post('/login', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
expect(response.status).toBe(202);
|
||||
expect(body.next_step).toBe('otp');
|
||||
return body.otp_jwt_token as string;
|
||||
};
|
||||
return { user, challenge };
|
||||
};
|
||||
|
||||
it('allows only one concurrent redemption of the same code', async () => {
|
||||
const { user, challenge } = await account(['RECOVER1', 'RECOVER2']);
|
||||
const tokens = await Promise.all([challenge(), challenge()]);
|
||||
overlapReads(user.uuid, tokens.length);
|
||||
const results = await Promise.all(
|
||||
tokens.map((token) =>
|
||||
post('/login/recovery-code', { token, code: 'RECOVER1' }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
results.filter((result) => result.body.next_step === 'complete'),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
results.filter((result) => result.body.proceed === false),
|
||||
).toHaveLength(1);
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.otp_recovery_codes).toBe(hashRecoveryCode('RECOVER2'));
|
||||
});
|
||||
|
||||
it('consumes different concurrent codes without restoring either one', async () => {
|
||||
const codes = ['RECOVER1', 'RECOVER2', 'RECOVER3'];
|
||||
const { user, challenge } = await account(codes);
|
||||
const tokens = await Promise.all(codes.map(() => challenge()));
|
||||
overlapReads(user.uuid, tokens.length);
|
||||
const results = await Promise.all(
|
||||
codes.map((code, index) =>
|
||||
post('/login/recovery-code', { token: tokens[index], code }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
results.every((result) => result.body.next_step === 'complete'),
|
||||
).toBe(true);
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.otp_recovery_codes).toBe('');
|
||||
const replay = await post('/login/recovery-code', {
|
||||
token: tokens[0],
|
||||
code: codes[0],
|
||||
});
|
||||
expect(replay.body).toMatchObject({ proceed: false });
|
||||
});
|
||||
|
||||
it.each([{ code: 123456 }, { code: {} }, { code: [] }])(
|
||||
'rejects a non-string recovery code $code without consuming a code',
|
||||
async ({ code }) => {
|
||||
const { user, challenge } = await account(['RECOVER1']);
|
||||
const result = await post('/login/recovery-code', {
|
||||
token: await challenge(),
|
||||
code,
|
||||
});
|
||||
expect(result.response.status).toBe(400);
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
}))!.otp_recovery_codes,
|
||||
).toBe(hashRecoveryCode('RECOVER1'));
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -154,8 +154,9 @@ export class EventsController extends PuterController {
|
||||
// -- Cross-user key-value handles --------------------------------
|
||||
|
||||
/**
|
||||
* POST /events/kv-handles — hand another user a watchable region of this
|
||||
* account's key-value data.
|
||||
* POST /events/kv-handles — hand another user a region of this account's
|
||||
* key-value data, `watch` unless the body asks for more. The older name for
|
||||
* `POST /kv/shares`, keeping its own body and response shapes.
|
||||
*
|
||||
* An account session, or an app session holding a `manage:` delegation on
|
||||
* the region — the service tells those apart and gates each rather than the
|
||||
@@ -167,12 +168,32 @@ export class EventsController extends PuterController {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
})
|
||||
async mintKvHandle(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.events.mintKvHandle(actor, bodyRecord(req)),
|
||||
const body = bodyRecord(req);
|
||||
const share = await this.services.kvShare.mintKvHandle(
|
||||
actor,
|
||||
{
|
||||
prefix: body.prefix,
|
||||
recipient: {
|
||||
username:
|
||||
typeof body.granteeUsername === 'string'
|
||||
? body.granteeUsername
|
||||
: undefined,
|
||||
uid:
|
||||
typeof body.granteeUid === 'string'
|
||||
? body.granteeUid
|
||||
: undefined,
|
||||
},
|
||||
access: body.access,
|
||||
appUid: body.appUid,
|
||||
},
|
||||
// Callers of this route mint again to get the same handle back.
|
||||
{ defaultAccess: 'watch', keepLevelUnlessNamed: true },
|
||||
);
|
||||
res.json({ handle: share.handle, prefix: share.prefix });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,7 +210,7 @@ export class EventsController extends PuterController {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
|
||||
const page = await this.services.events.listKvHandles(actor, {
|
||||
const page = await this.services.kvShare.listKvHandles(actor, {
|
||||
limit: normalizeLimit(query.limit, {
|
||||
cap: KV_HANDLE_LIST_LIMIT_CAP,
|
||||
}),
|
||||
@@ -198,7 +219,10 @@ export class EventsController extends PuterController {
|
||||
});
|
||||
|
||||
res.json({
|
||||
items: page.items,
|
||||
items: page.items.map(({ holderUsername, ...row }) => ({
|
||||
...row,
|
||||
granteeUsername: holderUsername,
|
||||
})),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total !== undefined ? { total: page.total } : {}),
|
||||
});
|
||||
@@ -217,7 +241,10 @@ export class EventsController extends PuterController {
|
||||
async revokeKvHandle(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.events.revokeKvHandle(actor, req.params.handle),
|
||||
await this.services.kvShare.revokeKvHandle(
|
||||
actor,
|
||||
req.params.handle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,16 @@ export const EVENTS_ACK_LIMIT = userWindow('events:ack', 600);
|
||||
*/
|
||||
export const EVENTS_KV_HANDLE_LIMIT = userWindow('events:kvHandles', 60);
|
||||
|
||||
/**
|
||||
* `kv.share.received` notices per hour, per owner and holder. A share past it
|
||||
* is still made, without a notice.
|
||||
*/
|
||||
export const KV_SHARE_NOTICE_LIMIT = userWindow(
|
||||
'kvShare:notices',
|
||||
5,
|
||||
60 * 60_000,
|
||||
);
|
||||
|
||||
/**
|
||||
* Live share handles one account may hold out at a time, across every app.
|
||||
*
|
||||
|
||||
@@ -1948,6 +1948,27 @@ describe('FSController.readEntry (file streaming)', () => {
|
||||
expect(captured.statusCode).toBe(206);
|
||||
});
|
||||
|
||||
it('returns 206 with Range honored when offset and byte_count query params are supplied', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const body = Buffer.from('abcdefghij');
|
||||
const target = `/${username}/Documents/ranged-query.bin`;
|
||||
await writeFile(userId, target, body, 'application/octet-stream');
|
||||
|
||||
const { res, captured } = makeStreamingRes();
|
||||
await withActor(actor, () =>
|
||||
controller.readEntry(
|
||||
makeReq({
|
||||
query: { path: target, offset: '2', byte_count: '4' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(captured.statusCode).toBe(206);
|
||||
expect(Buffer.concat(captured.bodyChunks).toString()).toBe('cdef');
|
||||
});
|
||||
|
||||
it('throws 404 when the path does not exist', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
loadLegacyAssociatedApps,
|
||||
replacedByMove,
|
||||
resolveOrNotFound,
|
||||
resolveReadRange,
|
||||
signEntryThumbnail,
|
||||
} from './legacyFsHelpers.js';
|
||||
import type {
|
||||
@@ -1495,10 +1496,7 @@ export class FSController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
const range =
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
const range = resolveReadRange(req.headers.range, query);
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
|
||||
@@ -3732,6 +3732,63 @@ describe('LegacyFSController.read file streaming', () => {
|
||||
);
|
||||
expect(captured.headers['Content-Type']).toMatch(/text\/html/);
|
||||
});
|
||||
|
||||
it('honors offset and byte_count query params on /read', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const body = Buffer.from('0123456789abcdef');
|
||||
const target = `/${username}/Documents/range-test.txt`;
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path: target,
|
||||
size: body.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: body,
|
||||
});
|
||||
|
||||
const { res, captured } = makeStreamingRes();
|
||||
await withActor(actor, () =>
|
||||
controller.read(
|
||||
makeReq({
|
||||
query: { file: target, offset: '4', byte_count: '6' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(captured.statusCode).toBe(206);
|
||||
expect(Buffer.concat(captured.bodyChunks).toString()).toBe('456789');
|
||||
});
|
||||
|
||||
it('rejects invalid offset query param with 400', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const body = Buffer.from('test');
|
||||
const target = `/${username}/Documents/invalid-offset.txt`;
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path: target,
|
||||
size: body.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: body,
|
||||
});
|
||||
|
||||
const { res } = makeStreamingRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.read(
|
||||
makeReq({
|
||||
query: { file: target, offset: '-1' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── /file directory listing ─────────────────────────────────────────
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Team2faLookup } from '../../core/http/middleware/gates.js';
|
||||
import Busboy from 'busboy';
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import { contentType as contentTypeFromMime } from 'mime-types';
|
||||
@@ -102,6 +103,7 @@ import {
|
||||
type GuiItemEventName,
|
||||
loadLegacyAssociatedApps,
|
||||
replacedByMove,
|
||||
resolveReadRange,
|
||||
resolveV1Selector,
|
||||
signEntry,
|
||||
signingConfigFromAppConfig,
|
||||
@@ -1287,10 +1289,7 @@ export class LegacyFSController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const range =
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
const range = resolveReadRange(req.headers.range, query);
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
@@ -1348,7 +1347,7 @@ export class LegacyFSController extends PuterController {
|
||||
// that guard every other authenticated FS route have to run here.
|
||||
assertNotSuspended(actor!.user);
|
||||
assertVerifiedAccount(actor!.user);
|
||||
await assertTeam2fa(actor!.user, this.stores.team);
|
||||
await assertTeam2fa(actor!.user, this.stores.team as Team2faLookup);
|
||||
|
||||
req.actor = assertResolvedActor(actor!);
|
||||
Context.set('actor', actor);
|
||||
@@ -1803,10 +1802,7 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
// File: stream bytes with Range support.
|
||||
const range =
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
const range = resolveReadRange(req.headers.range, query);
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
getBoolean,
|
||||
getString,
|
||||
loadLegacyAssociatedApps,
|
||||
resolveReadRange,
|
||||
signEntry,
|
||||
signEntryThumbnail,
|
||||
signingConfigFromAppConfig,
|
||||
@@ -618,3 +619,89 @@ describe('toLegacyEntry is_empty against stored children', () => {
|
||||
expect((await shape()).is_empty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveReadRange', () => {
|
||||
it('returns the explicit headersRange when provided', () => {
|
||||
expect(
|
||||
resolveReadRange('bytes=10-20', { offset: 0, byte_count: 5 }),
|
||||
).toBe('bytes=10-20');
|
||||
});
|
||||
|
||||
it('returns undefined when neither headersRange nor range query params are present', () => {
|
||||
expect(resolveReadRange(undefined, {})).toBeUndefined();
|
||||
expect(resolveReadRange('', {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('derives a byte range from offset and byte_count', () => {
|
||||
expect(resolveReadRange(undefined, { offset: 0, byte_count: 5 })).toBe(
|
||||
'bytes=0-4',
|
||||
);
|
||||
expect(
|
||||
resolveReadRange(undefined, { offset: 10, byte_count: 20 }),
|
||||
).toBe('bytes=10-29');
|
||||
expect(
|
||||
resolveReadRange(undefined, { offset: '4', byte_count: '6' }),
|
||||
).toBe('bytes=4-9');
|
||||
});
|
||||
|
||||
it('derives an open range when only offset is provided', () => {
|
||||
expect(resolveReadRange(undefined, { offset: 10 })).toBe('bytes=10-');
|
||||
expect(resolveReadRange(undefined, { offset: '0' })).toBe('bytes=0-');
|
||||
});
|
||||
|
||||
it('derives a prefix range when only byte_count is provided', () => {
|
||||
expect(resolveReadRange(undefined, { byte_count: 15 })).toBe(
|
||||
'bytes=0-14',
|
||||
);
|
||||
expect(resolveReadRange(undefined, { byte_count: '1' })).toBe(
|
||||
'bytes=0-0',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws 400 on invalid or negative offset', () => {
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: -1, byte_count: 10 }),
|
||||
).toThrow('Invalid offset');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 1.5, byte_count: 10 }),
|
||||
).toThrow('Invalid offset');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 'abc', byte_count: 10 }),
|
||||
).toThrow('Invalid offset');
|
||||
});
|
||||
|
||||
it('throws 400 on non-decimal or unsafe numeric strings', () => {
|
||||
for (const offset of [
|
||||
'1e3',
|
||||
'0x10',
|
||||
' 4',
|
||||
'1e21',
|
||||
'9007199254740993',
|
||||
]) {
|
||||
expect(() => resolveReadRange(undefined, { offset })).toThrow(
|
||||
'Invalid offset',
|
||||
);
|
||||
}
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, {
|
||||
offset: String(Number.MAX_SAFE_INTEGER),
|
||||
byte_count: '3',
|
||||
}),
|
||||
).toThrow('Invalid byte_count');
|
||||
});
|
||||
|
||||
it('throws 400 on non-positive or invalid byte_count', () => {
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 0, byte_count: 0 }),
|
||||
).toThrow('Invalid byte_count');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 0, byte_count: -5 }),
|
||||
).toThrow('Invalid byte_count');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 0, byte_count: 2.5 }),
|
||||
).toThrow('Invalid byte_count');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 0, byte_count: 'xyz' }),
|
||||
).toThrow('Invalid byte_count');
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,57 @@ export function getBoolean(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Non-negative safe integer from a number or a plain digit string; anything
|
||||
// else (exponents, hex, whitespace) is rejected.
|
||||
function parseByteParam(raw: unknown, name: string, min: number): number {
|
||||
const value =
|
||||
typeof raw === 'number'
|
||||
? raw
|
||||
: typeof raw === 'string' && /^\d+$/.test(raw)
|
||||
? Number(raw)
|
||||
: NaN;
|
||||
if (!Number.isSafeInteger(value) || value < min) {
|
||||
throw new HttpError(400, `Invalid ${name}`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an HTTP Range header string from explicit `headersRange` or fallback
|
||||
* `offset` / `byte_count` query parameters.
|
||||
*/
|
||||
export function resolveReadRange(
|
||||
headersRange: unknown,
|
||||
query: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
if (typeof headersRange === 'string' && headersRange.length > 0) {
|
||||
return headersRange;
|
||||
}
|
||||
const isSet = (v: unknown) => v !== undefined && v !== null && v !== '';
|
||||
const offset = isSet(query.offset)
|
||||
? parseByteParam(query.offset, 'offset', 0)
|
||||
: undefined;
|
||||
const byteCount = isSet(query.byte_count)
|
||||
? parseByteParam(query.byte_count, 'byte_count', 1)
|
||||
: undefined;
|
||||
|
||||
if (byteCount !== undefined) {
|
||||
const last = (offset ?? 0) + byteCount - 1;
|
||||
if (!Number.isSafeInteger(last)) {
|
||||
throw new HttpError(400, 'Invalid byte_count', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return `bytes=${offset ?? 0}-${last}`;
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
return `bytes=${offset}-`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Accepts either `{ path }` or `{ uid }` or `{ id }` or `{ parent, name }`
|
||||
// from a legacy body field. Returns a resolved entry (throwing 404 if not
|
||||
// found or 400 if no usable ref is present).
|
||||
|
||||
@@ -27,12 +27,12 @@ import { EventsController } from './events/EventsController.js';
|
||||
import { FSController } from './fs/FSController.js';
|
||||
import { HomepageController } from './homepage/HomepageController.js';
|
||||
import { HostingController } from './hosting/HostingController.js';
|
||||
import { KvShareController } from './kvShare/KvShareController.js';
|
||||
import { LegacyFSController } from './fs/LegacyFSController.js';
|
||||
import { NotificationController } from './notification/NotificationController.js';
|
||||
import { OIDCController } from './oidc/OIDCController.js';
|
||||
import { PuterAIController } from './puterai/PuterAIController.js';
|
||||
import { ShareController } from './share/ShareController.js';
|
||||
import { TeamController } from './team/TeamController.js';
|
||||
import { StaticAssetsController } from './static/StaticAssetsController.js';
|
||||
import { StaticPagesController } from './static/StaticPagesController.js';
|
||||
import { SystemController } from './system/SystemController.js';
|
||||
@@ -59,8 +59,8 @@ export const puterControllers = {
|
||||
broadcast: BroadcastController,
|
||||
notification: NotificationController,
|
||||
events: EventsController,
|
||||
kvShare: KvShareController,
|
||||
share: ShareController,
|
||||
team: TeamController,
|
||||
webdav: WebDAVController,
|
||||
oidc: OIDCController,
|
||||
wisp: WispController,
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
/*
|
||||
* 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 { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
type TestUserCredentials,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { EVENTS_KV_HANDLE_LIMIT } from '../events/limits.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
|
||||
const call = (
|
||||
env: PuterTestEnv,
|
||||
method: string,
|
||||
path: string,
|
||||
token?: string,
|
||||
body?: unknown,
|
||||
) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
const resetBudgets = async (env: PuterTestEnv) => {
|
||||
for (const name of [env.users.user.username, env.users.other.username]) {
|
||||
const user = await env.server.stores.user.getByUsername(name);
|
||||
await env.server.clients.redis.del(
|
||||
`rate:${EVENTS_KV_HANDLE_LIMIT.scope}:${user!.id}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
describe('/kv/shares', () => {
|
||||
let env: PuterTestEnv;
|
||||
let ownerUuid: string;
|
||||
const req = (
|
||||
method: string,
|
||||
path: string,
|
||||
token?: string,
|
||||
body?: unknown,
|
||||
) => call(env, method, path, token, body);
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
events: { enabled: true, kvHandles: true },
|
||||
kv: { shareHandles: true },
|
||||
unlimitedMetering: true,
|
||||
} as IConfig);
|
||||
const owner = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
ownerUuid = owner!.uuid as string;
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetBudgets(env);
|
||||
});
|
||||
|
||||
const shareWithOther = async (body: Record<string, unknown> = {}) => {
|
||||
const res = await req('POST', '/kv/shares', env.users.user.token, {
|
||||
prefix: `http-${uuidv4().slice(0, 8)}:`,
|
||||
recipient: env.users.other.username,
|
||||
...body,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
it('shares at read by default and answers in the owner`s shape', async () => {
|
||||
const shared = await shareWithOther({ label: 'Groceries' });
|
||||
expect(Object.keys(shared).sort()).toEqual(
|
||||
[
|
||||
'access',
|
||||
'appUid',
|
||||
'createdAt',
|
||||
'handle',
|
||||
'holder',
|
||||
'label',
|
||||
'prefix',
|
||||
].sort(),
|
||||
);
|
||||
expect(shared).toMatchObject({
|
||||
access: 'read',
|
||||
appUid: null,
|
||||
holder: env.users.other.username,
|
||||
label: 'Groceries',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the recipient as an object too, and refuses an address', async () => {
|
||||
await shareWithOther({
|
||||
recipient: { username: env.users.other.username },
|
||||
access: 'write',
|
||||
});
|
||||
const refused = await req('POST', '/kv/shares', env.users.user.token, {
|
||||
prefix: 'address:',
|
||||
recipient: '[email protected]',
|
||||
});
|
||||
expect(refused.status).toBe(400);
|
||||
await expect(refused.json()).resolves.toMatchObject({
|
||||
code: 'invalid_recipient',
|
||||
});
|
||||
});
|
||||
|
||||
it('lists what was shared out, and what was shared with the holder without the owner', async () => {
|
||||
const shared = await shareWithOther({ access: 'write', label: 'Trip' });
|
||||
|
||||
const byMe = await req(
|
||||
'GET',
|
||||
'/kv/shares?limit=200',
|
||||
env.users.user.token,
|
||||
);
|
||||
const byMePage = (await byMe.json()) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(
|
||||
byMePage.items.find((row) => row.handle === shared.handle),
|
||||
).toEqual({
|
||||
handle: shared.handle,
|
||||
prefix: shared.prefix,
|
||||
access: 'write',
|
||||
appUid: null,
|
||||
holder: env.users.other.username,
|
||||
label: 'Trip',
|
||||
createdAt: shared.createdAt,
|
||||
revokedAt: null,
|
||||
revokedBy: null,
|
||||
});
|
||||
|
||||
const withMe = await req(
|
||||
'GET',
|
||||
'/kv/shares/with-me?limit=200',
|
||||
env.users.other.token,
|
||||
);
|
||||
const withMeText = await withMe.text();
|
||||
const withMePage = JSON.parse(withMeText) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(
|
||||
withMePage.items.find((row) => row.handle === shared.handle),
|
||||
).toEqual({
|
||||
handle: shared.handle,
|
||||
access: 'write',
|
||||
appUid: null,
|
||||
label: 'Trip',
|
||||
createdAt: shared.createdAt,
|
||||
});
|
||||
expect(withMeText).not.toContain(env.users.user.username);
|
||||
expect(withMeText).not.toContain(ownerUuid);
|
||||
expect(withMeText).not.toContain(String(shared.prefix));
|
||||
});
|
||||
|
||||
it('lets the holder unshare, and shows the owner they did', async () => {
|
||||
const shared = await shareWithOther();
|
||||
const removed = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${shared.handle}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(removed.status).toBe(200);
|
||||
await expect(removed.json()).resolves.toMatchObject({
|
||||
handle: shared.handle,
|
||||
});
|
||||
|
||||
const byMe = (await (
|
||||
await req('GET', '/kv/shares?limit=200', env.users.user.token)
|
||||
).json()) as { items: Array<Record<string, unknown>> };
|
||||
expect(
|
||||
byMe.items.find((row) => row.handle === shared.handle),
|
||||
).toMatchObject({ revokedBy: 'holder' });
|
||||
});
|
||||
|
||||
it('keeps the owner and prefix out of the holder`s grant audit after they unshare', async () => {
|
||||
const shared = await shareWithOther({ access: 'write' });
|
||||
const segment = String(shared.prefix).slice(0, -1);
|
||||
const other = await env.server.stores.user.getByUsername(
|
||||
env.users.other.username,
|
||||
);
|
||||
// A row an earlier build filed under the holder.
|
||||
await env.server.stores.permission.auditUserUserPerm({
|
||||
holder_user_id: other!.id,
|
||||
issuer_user_id: other!.id,
|
||||
permission: `kv-share-write:${ownerUuid}:os-global:${segment}`,
|
||||
action: 'revoke',
|
||||
reason: 'kv share handle unshared',
|
||||
});
|
||||
const removed = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${shared.handle}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(removed.status).toBe(200);
|
||||
|
||||
// The revoke is filed under the owner, who granted it.
|
||||
const auditOf = async (token: string) => {
|
||||
const res = await req('GET', '/share/audit?limit=200', token);
|
||||
expect(res.status).toBe(200);
|
||||
return await res.text();
|
||||
};
|
||||
await vi.waitFor(async () => {
|
||||
const owners = JSON.parse(await auditOf(env.users.user.token)) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(owners.items).toContainEqual(
|
||||
expect.objectContaining({
|
||||
action: 'revoke',
|
||||
issuer: env.users.user.username,
|
||||
holder: env.users.other.username,
|
||||
permission: expect.stringContaining(segment),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const holders = await auditOf(env.users.other.token);
|
||||
expect(holders).not.toContain(ownerUuid);
|
||||
expect(holders).not.toContain(segment);
|
||||
});
|
||||
|
||||
it('lets the owner revoke', async () => {
|
||||
const shared = await shareWithOther();
|
||||
const removed = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${shared.handle}`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(removed.status).toBe(200);
|
||||
});
|
||||
|
||||
it('answers anyone else, and an unknown handle, with the same 404', async () => {
|
||||
const shared = await shareWithOther();
|
||||
const stranger = await createTestUser(env.server, {
|
||||
username: `kvs-${uuidv4().slice(0, 8)}`,
|
||||
password: 'pw-test-1234',
|
||||
});
|
||||
const strangers = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${shared.handle}`,
|
||||
stranger.token,
|
||||
);
|
||||
const unknownHandle = `kvh-${uuidv4()}`;
|
||||
const unknown = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${unknownHandle}`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(strangers.status).toBe(404);
|
||||
expect(unknown.status).toBe(404);
|
||||
const a = (await strangers.json()) as Record<string, unknown>;
|
||||
const b = (await unknown.json()) as Record<string, unknown>;
|
||||
expect(a.code).toBe(b.code);
|
||||
expect(a.message).toBe(`No such handle: ${shared.handle}`);
|
||||
expect(b.message).toBe(`No such handle: ${unknownHandle}`);
|
||||
});
|
||||
|
||||
it('turns an anonymous caller away', async () => {
|
||||
const res = await req('GET', '/kv/shares');
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/events/kv-handles beside /kv/shares', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
events: { enabled: true, kvHandles: true },
|
||||
kv: { shareHandles: true },
|
||||
unlimitedMetering: true,
|
||||
} as IConfig);
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
it('keeps its own request and response shapes, at watch by default', async () => {
|
||||
const minted = await call(
|
||||
env,
|
||||
'POST',
|
||||
'/events/kv-handles',
|
||||
env.users.user.token,
|
||||
{
|
||||
granteeUsername: env.users.other.username,
|
||||
prefix: 'alias-shape:',
|
||||
},
|
||||
);
|
||||
expect(minted.status).toBe(200);
|
||||
const body = (await minted.json()) as Record<string, unknown>;
|
||||
expect(body).toEqual({ handle: body.handle, prefix: 'alias-shape:' });
|
||||
|
||||
const listed = (await (
|
||||
await call(
|
||||
env,
|
||||
'GET',
|
||||
'/events/kv-handles?limit=200',
|
||||
env.users.user.token,
|
||||
)
|
||||
).json()) as { items: Array<Record<string, unknown>> };
|
||||
const row = listed.items.find((one) => one.handle === body.handle);
|
||||
expect(row).toMatchObject({
|
||||
prefix: 'alias-shape:',
|
||||
appUid: 'os-global',
|
||||
granteeUsername: env.users.other.username,
|
||||
access: 'watch',
|
||||
revokedBy: null,
|
||||
});
|
||||
expect(row).not.toHaveProperty('holder');
|
||||
|
||||
// The alias revoke stays the owner's: the holder is told it is absent.
|
||||
const byHolder = await call(
|
||||
env,
|
||||
'DELETE',
|
||||
`/events/kv-handles/${body.handle}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(byHolder.status).toBe(404);
|
||||
});
|
||||
|
||||
it('hands back a share it finds at the level it has, unless the body names one', async () => {
|
||||
const shared = (await (
|
||||
await call(env, 'POST', '/kv/shares', env.users.user.token, {
|
||||
prefix: 'alias-keep:',
|
||||
recipient: env.users.other.username,
|
||||
access: 'write',
|
||||
})
|
||||
).json()) as Record<string, unknown>;
|
||||
const accessOf = async () => {
|
||||
const listed = (await (
|
||||
await call(
|
||||
env,
|
||||
'GET',
|
||||
'/kv/shares?limit=200',
|
||||
env.users.user.token,
|
||||
)
|
||||
).json()) as { items: Array<Record<string, unknown>> };
|
||||
return listed.items.find((one) => one.handle === shared.handle)
|
||||
?.access;
|
||||
};
|
||||
const mintAlias = (extra: Record<string, unknown> = {}) =>
|
||||
call(env, 'POST', '/events/kv-handles', env.users.user.token, {
|
||||
granteeUsername: env.users.other.username,
|
||||
prefix: 'alias-keep:',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const again = await mintAlias();
|
||||
expect(again.status).toBe(200);
|
||||
await expect(again.json()).resolves.toEqual({
|
||||
handle: shared.handle,
|
||||
prefix: 'alias-keep:',
|
||||
});
|
||||
expect(await accessOf()).toBe('write');
|
||||
|
||||
expect((await mintAlias({ access: 'read' })).status).toBe(200);
|
||||
expect(await accessOf()).toBe('read');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Sharing asks for a confirmed email and nothing else. Booted with an SMS
|
||||
* provider configured, so a phone requirement would bite here if one were ever
|
||||
* added to these routes.
|
||||
*/
|
||||
describe('verification on the mint routes', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
events: { enabled: true, kvHandles: true },
|
||||
kv: { shareHandles: true },
|
||||
unlimitedMetering: true,
|
||||
strict_email_verification_required: true,
|
||||
prelude: { apiKey: 'test-key' },
|
||||
} as IConfig);
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const makeUser = async (
|
||||
patch: Record<string, unknown> = {},
|
||||
): Promise<TestUserCredentials> => {
|
||||
const username = `kvv${uuidv4().slice(0, 8)}`;
|
||||
const creds = await createTestUser(env.server, {
|
||||
username,
|
||||
password: 'pw-test-1234',
|
||||
});
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const row = await env.server.stores.user.getByUsername(username);
|
||||
await env.server.stores.user.update(row!.id, patch);
|
||||
}
|
||||
return creds;
|
||||
};
|
||||
|
||||
const mintBoth = (owner: TestUserCredentials, prefix: string) =>
|
||||
Promise.all([
|
||||
call(env, 'POST', '/kv/shares', owner.token, {
|
||||
prefix: `${prefix}a:`,
|
||||
recipient: env.users.other.username,
|
||||
}),
|
||||
call(env, 'POST', '/events/kv-handles', owner.token, {
|
||||
prefix: `${prefix}b:`,
|
||||
granteeUsername: env.users.other.username,
|
||||
}),
|
||||
]);
|
||||
|
||||
it('refuses an account whose email is not confirmed, on both routes', async () => {
|
||||
const owner = await makeUser();
|
||||
for (const res of await mintBoth(owner, 'unconfirmed')) {
|
||||
expect(res.status).toBe(403);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
code: 'account_is_not_verified',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a confirmed account share with no phone and no card', async () => {
|
||||
const owner = await makeUser({
|
||||
email: `kvv-${uuidv4().slice(0, 8)}@example.invalid`,
|
||||
email_confirmed: 1,
|
||||
});
|
||||
for (const res of await mintBoth(owner, 'confirmed'))
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('never gates listing or unsharing', async () => {
|
||||
const owner = await makeUser();
|
||||
for (const path of ['/kv/shares', '/kv/shares/with-me'])
|
||||
expect((await call(env, 'GET', path, owner.token)).status).toBe(
|
||||
200,
|
||||
);
|
||||
const removed = await call(
|
||||
env,
|
||||
'DELETE',
|
||||
`/kv/shares/kvh-${uuidv4()}`,
|
||||
owner.token,
|
||||
);
|
||||
expect(removed.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -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 type { Request, Response } from 'express';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { Controller, Delete, Get, Post } from '../../core/http/decorators.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { bodyRecord } from '../../core/http/requestBody.js';
|
||||
import { parseKvShareRecipient } from '../../services/kvShare/kvShares.js';
|
||||
import type { KvShareHandleListOptions } from '../../stores/events/KvShareHandleStore.js';
|
||||
import { KV_HANDLE_LIST_LIMIT_CAP } from '../../stores/events/KvShareHandleStore.js';
|
||||
import { KV_GLOBAL_APP_KEY } from '../../stores/systemKv/SystemKVStore.js';
|
||||
import { normalizeLimit } from '../../util/pagination.js';
|
||||
import { EVENTS_LIST_LIMIT } from '../events/limits.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
/** The app-less namespace is no app, so the wire says `null` for it. */
|
||||
const publicAppUid = (appUid: string): string | null =>
|
||||
appUid === KV_GLOBAL_APP_KEY ? null : appUid;
|
||||
|
||||
const pageOptions = (req: Request): KvShareHandleListOptions => {
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
limit: normalizeLimit(query.limit, { cap: KV_HANDLE_LIST_LIMIT_CAP }),
|
||||
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
|
||||
includeTotal: query.includeTotal === 'true',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Sharing a region of one account's key-value data with another account.
|
||||
*
|
||||
* Gated like the events surface: `subdomain: 'api'` plus `allowAccessToken`,
|
||||
* with app-ness decided from `effectiveApp` in the service, which is where the
|
||||
* owner, holder, delegation and namespace rules live. Only sharing asks for a
|
||||
* confirmed email; listing, revoking and unsharing only narrow or report.
|
||||
*/
|
||||
@Controller('/kv')
|
||||
export class KvShareController extends PuterController {
|
||||
/**
|
||||
* POST /kv/shares — share a region with another account, `read` unless the
|
||||
* body asks otherwise. Sharing the same region with the same account again
|
||||
* changes its level or label in place.
|
||||
*/
|
||||
@Post('/shares', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
})
|
||||
async share(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = bodyRecord(req);
|
||||
const share = await this.services.kvShare.mintKvHandle(
|
||||
actor,
|
||||
{
|
||||
prefix: body.prefix,
|
||||
recipient: parseKvShareRecipient(body.recipient),
|
||||
access: body.access,
|
||||
appUid: body.appUid,
|
||||
label: body.label,
|
||||
},
|
||||
{ defaultAccess: 'read' },
|
||||
);
|
||||
res.json({
|
||||
handle: share.handle,
|
||||
prefix: share.prefix,
|
||||
access: share.access,
|
||||
appUid: publicAppUid(share.appUid),
|
||||
holder: share.holderUsername,
|
||||
label: share.label,
|
||||
createdAt: share.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /kv/shares — what this account has shared out, retired included. */
|
||||
@Get('/shares', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_LIST_LIMIT,
|
||||
})
|
||||
async listSharedByMe(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const page = await this.services.kvShare.listKvHandles(
|
||||
actor,
|
||||
pageOptions(req),
|
||||
);
|
||||
res.json({
|
||||
items: page.items.map((row) => ({
|
||||
handle: row.handle,
|
||||
prefix: row.prefix,
|
||||
access: row.access,
|
||||
appUid: publicAppUid(row.appUid),
|
||||
holder: row.holderUsername,
|
||||
label: row.label,
|
||||
createdAt: row.createdAt,
|
||||
revokedAt: row.revokedAt,
|
||||
revokedBy: row.revokedBy,
|
||||
})),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total !== undefined ? { total: page.total } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /kv/shares/with-me — live shares this account holds. */
|
||||
@Get('/shares/with-me', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_LIST_LIMIT,
|
||||
})
|
||||
async listSharedWithMe(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const page = await this.services.kvShare.listKvSharesWithMe(
|
||||
actor,
|
||||
pageOptions(req),
|
||||
);
|
||||
res.json({
|
||||
items: page.items.map((row) => ({
|
||||
handle: row.handle,
|
||||
access: row.access,
|
||||
appUid: publicAppUid(row.appUid),
|
||||
label: row.label,
|
||||
createdAt: row.createdAt,
|
||||
})),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total !== undefined ? { total: page.total } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /kv/shares/:handle — the owner revokes, the holder gives it up,
|
||||
* and anyone else is told it is not there.
|
||||
*/
|
||||
@Delete('/shares/:handle', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async unshare(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.kvShare.removeKvShare(actor, req.params.handle),
|
||||
);
|
||||
}
|
||||
|
||||
#requireActor(req: Request): Actor {
|
||||
const actor = req.actor;
|
||||
if (!actor?.user)
|
||||
throw new HttpError(401, 'Unauthorized', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
return actor;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { installSeatStub } from '../../testFixtures/seatStub.js';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import {
|
||||
afterAll,
|
||||
@@ -233,20 +234,14 @@ const callRoute = async (
|
||||
);
|
||||
};
|
||||
|
||||
const oidc = (): OIDCService =>
|
||||
server.services.oidc as unknown as OIDCService;
|
||||
const oidc = (): OIDCService => server.services.oidc as unknown as OIDCService;
|
||||
|
||||
// ── GET /auth/oidc/providers ────────────────────────────────────────
|
||||
|
||||
describe('OIDCController GET /auth/oidc/providers', () => {
|
||||
it('lists every provider whose config validates', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/providers',
|
||||
makeReq({}),
|
||||
res,
|
||||
);
|
||||
await callRoute('get', '/auth/oidc/providers', makeReq({}), res);
|
||||
// We configured a single `custom` provider; the live service
|
||||
// walks the config and returns its id.
|
||||
expect(captured.body).toEqual({ providers: ['custom'] });
|
||||
@@ -417,9 +412,9 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
|
||||
}),
|
||||
res,
|
||||
);
|
||||
const state = new URL(
|
||||
captured.redirectUrl ?? '',
|
||||
).searchParams.get('state');
|
||||
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
|
||||
'state',
|
||||
);
|
||||
const decoded = oidc().verifyState(state!);
|
||||
expect(String(decoded?.redirect_uri)).toBe(TEST_ORIGIN);
|
||||
}
|
||||
@@ -624,10 +619,7 @@ describe('OIDCController login callback', () => {
|
||||
|
||||
// The real OIDCService linked the new user — verify via the
|
||||
// live store.
|
||||
const linkedUser = await oidc().findUserByProviderSub(
|
||||
'custom',
|
||||
sub,
|
||||
);
|
||||
const linkedUser = await oidc().findUserByProviderSub('custom', sub);
|
||||
expect(linkedUser).not.toBeNull();
|
||||
expect(linkedUser?.email).toBe(email);
|
||||
});
|
||||
@@ -791,14 +783,12 @@ describe('OIDCController login callback', () => {
|
||||
// Seed by running the linked-sub path first via createUserFromOIDC.
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `oidc-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
const firstUserId = created.user!.id;
|
||||
@@ -838,14 +828,12 @@ describe('OIDCController login callback', () => {
|
||||
it('redirects with auth_error when the user is suspended', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
// Flip the suspended bit on the existing row.
|
||||
@@ -980,14 +968,12 @@ describe('OIDCController login callback', () => {
|
||||
it('keeps an /app/<name> landing in the error redirect (suspended user)', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `sus-app-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
await server.stores.user.update(created.user!.id, { suspended: 1 });
|
||||
@@ -1075,14 +1061,12 @@ describe('OIDCController login callback', () => {
|
||||
it('uses popup-style error URL (msg_id + opener_origin) when the popup-state user is suspended', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `pop-sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
await server.stores.user.update(created.user!.id, { suspended: 1 });
|
||||
@@ -1232,13 +1216,7 @@ describe('OIDCController login callback', () => {
|
||||
email,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
});
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
installSeatStub(server as never, [seat.id]);
|
||||
// Provisioning issues a temporary password once the seat exists.
|
||||
await server.stores.user.update(seat.id, { password: 'hashed' });
|
||||
return seat;
|
||||
@@ -1334,14 +1312,12 @@ describe('OIDCController signup veto (abuse harness)', () => {
|
||||
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
vetoWithTrail(email, 'trail-svc-1');
|
||||
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(false);
|
||||
expect(created.code).toBe('signup_blocked');
|
||||
@@ -1676,14 +1652,12 @@ describe('OIDCController signup callback', () => {
|
||||
// resolves to `linked-sub` rather than creating a new user.
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `existing-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
|
||||
@@ -1755,14 +1729,12 @@ describe('OIDCController signup callback', () => {
|
||||
it('redirects suspended users to action=signup with message=account_suspended', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
await server.stores.user.update(created.user!.id, { suspended: 1 });
|
||||
@@ -1899,14 +1871,12 @@ describe('OIDCController revalidate callback', () => {
|
||||
// The linked account exists, but state.user_uuid points at someone else.
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
|
||||
@@ -1938,14 +1908,12 @@ describe('OIDCController revalidate callback', () => {
|
||||
it('happy path: sets the revalidation cookie and redirects to redirect-done', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
const userUuid = created.user!.uuid;
|
||||
@@ -1992,14 +1960,12 @@ describe('OIDCController revalidate callback', () => {
|
||||
it('falls back to /auth/revalidate-done when state.redirect_uri is cross-origin', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
const created = await runWithContext({ req: makeReq({}) }, () =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
const userUuid = created.user!.uuid;
|
||||
@@ -2038,12 +2004,7 @@ describe('OIDCController revalidate callback', () => {
|
||||
describe('OIDCController GET /auth/revalidate-done', () => {
|
||||
it('renders the postMessage HTML body with the configured origin', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/revalidate-done',
|
||||
makeReq({}),
|
||||
res,
|
||||
);
|
||||
await callRoute('get', '/auth/revalidate-done', makeReq({}), res);
|
||||
expect(captured.contentType).toContain('text/html');
|
||||
const body = String(captured.body);
|
||||
expect(body).toContain('puter-revalidate-done');
|
||||
@@ -2057,9 +2018,9 @@ describe('OIDCController GET /auth/revalidate-done', () => {
|
||||
/**
|
||||
* The proof exists because the popup return leg states two things the popup
|
||||
* cannot check — the opener's origin and that a login completed — and a URL
|
||||
* built from a verified `state` is byte-identical to one anybody can type.
|
||||
* The opener's origin picks the app a token is minted for, so it has to be
|
||||
* attested rather than read.
|
||||
* built from a verified `state` is byte-identical to one anybody can type. The
|
||||
* opener's origin picks the app a token is minted for, so it has to be attested
|
||||
* rather than read.
|
||||
*/
|
||||
describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
|
||||
const NONCE = 'binding-nonce';
|
||||
|
||||
@@ -24,6 +24,12 @@ import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { startWebSession } from '../auth/webSession.js';
|
||||
import {
|
||||
completeOidcLoginReturn,
|
||||
mintOidcPopupReturn,
|
||||
OIDC_POPUP_RETURN_COOKIE_NAME,
|
||||
type OidcLoginContinuation,
|
||||
} from '../auth/oidcLoginReturn.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { normalizeBonusCode } from '../../util/signupBonus.js';
|
||||
@@ -43,11 +49,6 @@ const REVALIDATION_EXPIRY_SEC = 300;
|
||||
const OIDC_NONCE_COOKIE_NAME = 'puter_oidc_nonce';
|
||||
const OIDC_NONCE_EXPIRY_SEC = 600;
|
||||
|
||||
// Single-use, and binds a popup-return proof to the browser that earned it.
|
||||
// Expiry mirrors POPUP_RETURN_EXPIRY_SEC in OIDCService.
|
||||
const OIDC_POPUP_RETURN_COOKIE_NAME = 'puter_oidc_popup_return';
|
||||
const OIDC_POPUP_RETURN_EXPIRY_SEC = 300;
|
||||
|
||||
// The popup branch below hard-codes this return target.
|
||||
const POPUP_RETURN_ACTION = 'sign-in';
|
||||
|
||||
@@ -284,11 +285,6 @@ function buildErrorRedirectUrl(
|
||||
return `${base}${pagePath}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function appendQueryParam(url: string, key: string, value: string): string {
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
}
|
||||
|
||||
/** Length-safe constant-time string compare (never throws on mismatch). */
|
||||
function constantTimeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
@@ -803,18 +799,9 @@ if (window.opener) {
|
||||
|
||||
/** Sign a popup-return proof, bound to this browser and this popup. */
|
||||
#mintPopupReturn(res: Response, payload: Record<string, unknown>): string {
|
||||
const nonce = crypto.randomBytes(32).toString('base64url');
|
||||
res.cookie(OIDC_POPUP_RETURN_COOKIE_NAME, nonce, {
|
||||
// Redeemed same-origin.
|
||||
...sessionCookieFlags(this.config, { crossSite: false }),
|
||||
httpOnly: true,
|
||||
maxAge: OIDC_POPUP_RETURN_EXPIRY_SEC * 1000,
|
||||
path: '/',
|
||||
});
|
||||
return this.services.oidc.signPopupReturn({
|
||||
action: POPUP_RETURN_ACTION,
|
||||
...payload,
|
||||
nonce,
|
||||
return mintOidcPopupReturn(res, payload, {
|
||||
config: this.config,
|
||||
oidc: this.services.oidc,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -872,7 +859,7 @@ if (window.opener) {
|
||||
byEmail !== null &&
|
||||
!byEmail.email_confirmed &&
|
||||
userinfo.email_verified === true &&
|
||||
(await this.services.team.releaseUnconfirmedSeatEmail(
|
||||
(await this.services.team?.releaseUnconfirmedSeatEmail(
|
||||
byEmail.id,
|
||||
));
|
||||
if (byEmail && !releasedFromSeat) {
|
||||
@@ -1012,49 +999,71 @@ if (window.opener) {
|
||||
stateDecoded: Record<string, unknown>,
|
||||
extraQueryParams?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const origin = (this.config.origin ?? '').replace(/\/$/, '');
|
||||
const continuation: OidcLoginContinuation = {
|
||||
userUuid: user.uuid,
|
||||
redirectUri:
|
||||
typeof stateDecoded.redirect_uri === 'string'
|
||||
? stateDecoded.redirect_uri
|
||||
: origin || '/',
|
||||
embeddedInPopup: stateDecoded.embedded_in_popup === true,
|
||||
openerOrigin:
|
||||
typeof stateDecoded.opener_origin === 'string'
|
||||
? stateDecoded.opener_origin
|
||||
: null,
|
||||
messageId:
|
||||
stateDecoded.msg_id == null
|
||||
? null
|
||||
: String(stateDecoded.msg_id),
|
||||
switchedToLogin: extraQueryParams?.oidc_switched === 'login',
|
||||
};
|
||||
if (user.otp_enabled) {
|
||||
const challenge = this.services.token.sign(
|
||||
'otp',
|
||||
{
|
||||
purpose: 'otp-login',
|
||||
user_uid: user.uuid,
|
||||
jti: crypto.randomUUID(),
|
||||
oidcContinuation: continuation,
|
||||
},
|
||||
{ expiresIn: '5m' },
|
||||
);
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.set('Referrer-Policy', 'no-referrer');
|
||||
let retryUrl = `${origin}/action/login`;
|
||||
if (continuation.embeddedInPopup) {
|
||||
const retry = new URL(
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
'unauthorized',
|
||||
stateDecoded,
|
||||
undefined,
|
||||
(payload) => this.#mintPopupReturn(res, payload),
|
||||
),
|
||||
);
|
||||
retry.searchParams.delete('auth_error');
|
||||
retry.searchParams.delete('message');
|
||||
retryUrl = retry.href;
|
||||
}
|
||||
const fragment = new URLSearchParams({
|
||||
token: challenge,
|
||||
retryUrl,
|
||||
});
|
||||
res.redirect(302, `${origin}/action/oidc-2fa#${fragment}`);
|
||||
return;
|
||||
}
|
||||
await startWebSession(req, res, user, {
|
||||
config: this.config,
|
||||
auth: this.services.auth,
|
||||
});
|
||||
|
||||
const origin = (this.config.origin ?? '').replace(/\/$/, '');
|
||||
let target = (stateDecoded.redirect_uri as string) || origin || '/';
|
||||
if (!isSameOrigin(target, origin)) {
|
||||
target = origin || '/';
|
||||
}
|
||||
|
||||
if (stateDecoded.embedded_in_popup) {
|
||||
target = appendQueryParam(target, 'oidc_login', 'true');
|
||||
// `opener_origin` and `oidc_login` reach the popup as bare query
|
||||
// parameters, which say nothing about where they came from: the
|
||||
// URL a verified state produces is byte-identical to one anybody
|
||||
// can type. The popup treats the opener's origin as the app
|
||||
// identity to mint a token for, so it needs the integrity this
|
||||
// state already carries — re-signed here, at the one point where
|
||||
// the round trip is known to have actually happened.
|
||||
//
|
||||
// `user_uuid` binds the proof to the account that just completed
|
||||
// OIDC. Without it a proof from one login could be replayed in
|
||||
// another signed-in browser to skip its account picker; the popup
|
||||
// only honors `oidc_login` when this matches its current user.
|
||||
target = appendQueryParam(
|
||||
target,
|
||||
'opener_state',
|
||||
this.#mintPopupReturn(res, {
|
||||
opener_origin: stateDecoded.opener_origin ?? null,
|
||||
msg_id: stateDecoded.msg_id ?? null,
|
||||
oidc_login: true,
|
||||
user_uuid: user.uuid,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (extraQueryParams) {
|
||||
for (const [k, v] of Object.entries(extraQueryParams)) {
|
||||
if (v != null) target = appendQueryParam(target, k, v);
|
||||
}
|
||||
}
|
||||
|
||||
res.redirect(302, target);
|
||||
res.redirect(
|
||||
302,
|
||||
completeOidcLoginReturn(res, user, continuation, {
|
||||
config: this.config,
|
||||
oidc: this.services.oidc,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { TOTP } from 'otpauth';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
setupPuterTestEnv,
|
||||
createTestUser,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
|
||||
|
||||
describe('OIDC account second-factor login', () => {
|
||||
let env: PuterTestEnv;
|
||||
let claims: { sub: string; email: string; email_verified: boolean };
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
cookie_name: 'puter_auth_token',
|
||||
oidc: {
|
||||
providers: {
|
||||
custom: {
|
||||
client_id: 'test-client',
|
||||
client_secret: 'test-secret',
|
||||
authorization_endpoint: 'https://idp.example/authorize',
|
||||
token_endpoint: 'https://idp.example/token',
|
||||
userinfo_endpoint: 'https://idp.example/userinfo',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as IConfig);
|
||||
vi.spyOn(
|
||||
env.server.services.oidc,
|
||||
'exchangeCodeForTokens',
|
||||
).mockResolvedValue({ access_token: 'test-access' } as never);
|
||||
vi.spyOn(env.server.services.oidc, 'getUserInfo').mockImplementation(
|
||||
async () => claims,
|
||||
);
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const account = async (enabled = true, linked = true) => {
|
||||
const username = `oidc2fa-${uuidv4().slice(0, 8)}`;
|
||||
await createTestUser(env.server, {
|
||||
username,
|
||||
password: 'oidc-test-password',
|
||||
});
|
||||
const user = (await env.server.stores.user.getByUsername(username))!;
|
||||
const otp = new TOTP({
|
||||
issuer: 'puter.com',
|
||||
label: username,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
});
|
||||
claims = {
|
||||
sub: uuidv4(),
|
||||
email: `${username}@example.com`,
|
||||
email_verified: true,
|
||||
};
|
||||
await env.server.stores.user.update(user.id, {
|
||||
email: claims.email,
|
||||
email_confirmed: true,
|
||||
otp_enabled: enabled,
|
||||
otp_secret: otp.secret.base32,
|
||||
otp_recovery_codes: hashRecoveryCode('ABCDEFG2'),
|
||||
});
|
||||
if (linked) {
|
||||
await env.server.services.oidc.linkProviderToUser(
|
||||
user.id,
|
||||
'custom',
|
||||
claims,
|
||||
);
|
||||
}
|
||||
return { user, otp };
|
||||
};
|
||||
const callback = async (
|
||||
flow = 'login',
|
||||
extra: Record<string, string> = {},
|
||||
) => {
|
||||
const start = new URL(
|
||||
`/auth/oidc/custom/start?flow=${flow}`,
|
||||
env.origin,
|
||||
);
|
||||
Object.entries(extra).forEach(([key, value]) =>
|
||||
start.searchParams.set(key, value),
|
||||
);
|
||||
const begun = await fetch(start, { redirect: 'manual' });
|
||||
expect(begun.status).toBe(302);
|
||||
const state = new URL(begun.headers.get('location')!).searchParams.get(
|
||||
'state',
|
||||
)!;
|
||||
const nonce = begun.headers
|
||||
.getSetCookie()
|
||||
.find((value) => value.startsWith('puter_oidc_nonce='))!
|
||||
.split(';')[0]!;
|
||||
return fetch(
|
||||
new URL(
|
||||
`/auth/oidc/callback/${flow}?code=test-code&state=${encodeURIComponent(state)}`,
|
||||
env.origin,
|
||||
),
|
||||
{ redirect: 'manual', headers: { cookie: nonce } },
|
||||
);
|
||||
};
|
||||
const verify = (token: string, code: string, recovery = false) =>
|
||||
fetch(
|
||||
new URL(
|
||||
recovery ? '/login/recovery-code' : '/login/otp',
|
||||
env.origin,
|
||||
),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ token, code }),
|
||||
},
|
||||
);
|
||||
const challenge = (response: Response) => {
|
||||
expect(response.status).toBe(302);
|
||||
expect(
|
||||
response.headers
|
||||
.getSetCookie()
|
||||
.some((value) => value.startsWith('puter_auth_token=')),
|
||||
).toBe(false);
|
||||
const location = new URL(response.headers.get('location')!);
|
||||
expect(location.pathname).toBe('/action/oidc-2fa');
|
||||
const token = new URLSearchParams(location.hash.slice(1)).get('token');
|
||||
expect(token).toBeTruthy();
|
||||
return token!;
|
||||
};
|
||||
|
||||
it.each(['login', 'signup'])(
|
||||
'requires the authenticator before completing existing-account %s',
|
||||
async (flow) => {
|
||||
const { otp } = await account();
|
||||
const token = challenge(
|
||||
await callback(flow, { return_to: '/dashboard' }),
|
||||
);
|
||||
const response = await verify(token, otp.generate());
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
proceed: true,
|
||||
next_step: 'complete',
|
||||
redirectUrl: `${env.origin}/dashboard${flow === 'signup' ? '?oidc_switched=login' : ''}`,
|
||||
});
|
||||
expect(
|
||||
response.headers
|
||||
.getSetCookie()
|
||||
.some((value) => value.startsWith('puter_auth_token=')),
|
||||
).toBe(true);
|
||||
expect((await verify(token, otp.generate())).status).toBe(400);
|
||||
},
|
||||
);
|
||||
|
||||
it('incorrect codes do not start a session and allow a valid recovery-code retry', async () => {
|
||||
const { otp } = await account();
|
||||
const token = challenge(await callback());
|
||||
const wrong = ['000000', '000001', '000002'].find(
|
||||
(code) => otp.validate({ token: code }) === null,
|
||||
)!;
|
||||
const rejected = await verify(token, wrong);
|
||||
expect(await rejected.json()).toMatchObject({ proceed: false });
|
||||
expect(rejected.headers.getSetCookie()).toHaveLength(0);
|
||||
const accepted = await verify(token, 'ABCDEFG2', true);
|
||||
expect(accepted.status).toBe(200);
|
||||
expect(await accepted.json()).toMatchObject({
|
||||
proceed: true,
|
||||
redirectUrl: env.origin,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an unauthenticated popup retry and attests login only after verification', async () => {
|
||||
const { otp, user } = await account();
|
||||
const response = await callback('login', {
|
||||
embedded_in_popup: 'true',
|
||||
msg_id: 'message-123',
|
||||
opener_origin: 'https://app.example',
|
||||
});
|
||||
const token = challenge(response);
|
||||
const fragment = new URLSearchParams(
|
||||
new URL(response.headers.get('location')!).hash.slice(1),
|
||||
);
|
||||
const retry = new URL(fragment.get('retryUrl')!);
|
||||
expect(retry.origin).toBe(env.origin);
|
||||
expect(retry.searchParams.get('action')).toBe('login');
|
||||
expect(retry.searchParams.get('embedded_in_popup')).toBe('true');
|
||||
expect(retry.searchParams.get('msg_id')).toBe('message-123');
|
||||
expect(retry.searchParams.has('auth_error')).toBe(false);
|
||||
const retryNonce = response.headers
|
||||
.get('set-cookie')!
|
||||
.match(/puter_oidc_popup_return=([^;, ]+)/)![1];
|
||||
const retried = await fetch(
|
||||
new URL('/auth/oidc/verify-popup-return', env.origin),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
cookie: `puter_oidc_popup_return=${retryNonce}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
opener_state: retry.searchParams.get('opener_state'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(retried.status).toBe(200);
|
||||
expect(await retried.json()).toMatchObject({
|
||||
action: 'login',
|
||||
oidc_login: false,
|
||||
user_uuid: null,
|
||||
msg_id: 'message-123',
|
||||
opener_origin: 'https://app.example',
|
||||
});
|
||||
const accepted = await verify(token, otp.generate());
|
||||
const data = await accepted.json();
|
||||
const target = new URL(data.redirectUrl);
|
||||
expect(target.pathname).toBe('/action/sign-in');
|
||||
expect(target.searchParams.get('msg_id')).toBe('message-123');
|
||||
expect(target.searchParams.get('oidc_login')).toBe('true');
|
||||
const proof = env.server.services.oidc.verifyPopupReturn(
|
||||
target.searchParams.get('opener_state')!,
|
||||
);
|
||||
expect(proof).toMatchObject({
|
||||
user_uuid: user.uuid,
|
||||
opener_origin: 'https://app.example',
|
||||
msg_id: 'message-123',
|
||||
oidc_login: true,
|
||||
});
|
||||
expect(
|
||||
accepted.headers
|
||||
.getSetCookie()
|
||||
.some((value) => value.startsWith('puter_oidc_popup_return=')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('requires the authenticator when verified email first links an existing account', async () => {
|
||||
const { otp } = await account(true, false);
|
||||
const token = challenge(await callback());
|
||||
expect((await verify(token, otp.generate())).status).toBe(200);
|
||||
});
|
||||
|
||||
it('requires the authenticator for an OIDC-only account', async () => {
|
||||
const { user, otp } = await account();
|
||||
await env.server.stores.user.update(user.id, { password: null });
|
||||
const token = challenge(await callback());
|
||||
const response = await verify(token, otp.generate());
|
||||
expect(await response.json()).toMatchObject({
|
||||
proceed: true,
|
||||
next_step: 'complete',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses distinct short-lived challenges and refuses a modified token', async () => {
|
||||
const { otp } = await account();
|
||||
const first = challenge(await callback());
|
||||
const second = challenge(await callback());
|
||||
expect(first).not.toBe(second);
|
||||
const decoded = env.server.services.token.verify<{
|
||||
purpose: string;
|
||||
jti: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}>('otp', first);
|
||||
expect(decoded.purpose).toBe('otp-login');
|
||||
expect(decoded.jti).toBeTruthy();
|
||||
expect(decoded.exp - decoded.iat).toBe(300);
|
||||
const modified = `${first.slice(0, -4)}abcd`;
|
||||
const rejected = await verify(modified, otp.generate());
|
||||
expect(rejected.status).toBe(400);
|
||||
expect(rejected.headers.get('set-cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('validates continuation account binding before creating any session', async () => {
|
||||
const { otp } = await account();
|
||||
const issued = challenge(await callback());
|
||||
const decoded = env.server.services.token.verify<
|
||||
Record<string, unknown>
|
||||
>('otp', issued);
|
||||
const continuation = decoded.oidcContinuation as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const mismatched = env.server.services.token.sign('otp', {
|
||||
...decoded,
|
||||
jti: uuidv4(),
|
||||
oidcContinuation: { ...continuation, userUuid: uuidv4() },
|
||||
});
|
||||
const rejected = await verify(mismatched, otp.generate());
|
||||
expect(rejected.status).toBe(400);
|
||||
expect(rejected.headers.get('set-cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps a signed continuation redirect to the configured origin', async () => {
|
||||
const { otp } = await account();
|
||||
const issued = challenge(await callback());
|
||||
const decoded = env.server.services.token.verify<
|
||||
Record<string, unknown>
|
||||
>('otp', issued);
|
||||
const continuation = decoded.oidcContinuation as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const redirected = env.server.services.token.sign('otp', {
|
||||
...decoded,
|
||||
jti: uuidv4(),
|
||||
oidcContinuation: {
|
||||
...continuation,
|
||||
redirectUri: 'https://elsewhere.example/',
|
||||
},
|
||||
});
|
||||
const accepted = await verify(redirected, otp.generate());
|
||||
expect(await accepted.json()).toMatchObject({
|
||||
proceed: true,
|
||||
redirectUrl: env.origin,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves ordinary OIDC login for accounts without 2FA', async () => {
|
||||
await account(false);
|
||||
const response = await callback('login', { return_to: '/desktop' });
|
||||
expect(response.headers.get('location')).toBe(`${env.origin}/desktop`);
|
||||
expect(
|
||||
response.headers.get('set-cookie')?.includes('puter_auth_token='),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,978 +0,0 @@
|
||||
/**
|
||||
* 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import type { IConfig } from '../../types';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
const randomHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
describe('team endpoints over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
// The cap has its own suite; these tests need many teams.
|
||||
env = await setupPuterTestEnv({
|
||||
teams_enabled: true,
|
||||
max_teams_per_user: 100,
|
||||
} as IConfig);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const call = (
|
||||
method: string,
|
||||
path: string,
|
||||
token: string,
|
||||
body?: unknown,
|
||||
) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
/** An app token acting for `owner`, minted the way the dashboard does. */
|
||||
const makeAppToken = async (owner: { username: string }) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const actor = makeActor({ user: user! });
|
||||
const app = await env.server.stores.app.create(
|
||||
{
|
||||
name: `team-http-app-${crypto.randomUUID()}`,
|
||||
title: 'Team app',
|
||||
index_url: `https://team-${crypto.randomUUID()}.test/`,
|
||||
},
|
||||
{ ownerUserId: actor.user.id! },
|
||||
);
|
||||
return env.server.services.auth.getUserAppToken(actor, app.uid);
|
||||
};
|
||||
|
||||
/** A scoped (non-full-access) access token for `owner`, carrying no app. */
|
||||
const makeScopedToken = async (owner: {
|
||||
username: string;
|
||||
token: string;
|
||||
}) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const anchor = `/${owner.username}/${crypto.randomUUID()}`;
|
||||
await env.server.services.fs.mkdir(user!.id, {
|
||||
path: anchor,
|
||||
createMissingParents: true,
|
||||
});
|
||||
const entry = await env.server.stores.fsEntry.getEntryByPath(anchor);
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
owner.token,
|
||||
);
|
||||
return env.server.services.auth.createAccessToken(
|
||||
actor!,
|
||||
[[`fs:${entry!.uid}:list`]],
|
||||
{ label: 'teams-scope' },
|
||||
);
|
||||
};
|
||||
|
||||
/** A team owned by `user`, with one provisioned member. */
|
||||
const makeTeam = async () => {
|
||||
const res = await call('POST', '/teams', env.users.user.token, {
|
||||
name: 'Acme',
|
||||
handle: randomHandle(),
|
||||
});
|
||||
// Unasserted, a broken create means tests run on `/teams/undefined`.
|
||||
expect(res.status).toBe(200);
|
||||
const team = (await res.json()) as { uid: string };
|
||||
expect(team.uid).toBeTruthy();
|
||||
|
||||
const username = `http_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const member = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.token,
|
||||
{ username, email: `${username}@test.local` },
|
||||
);
|
||||
expect(member.status).toBe(200);
|
||||
return { team, memberUsername: username };
|
||||
};
|
||||
|
||||
// -- the owner-account gate --------------------------------------
|
||||
|
||||
it('creates a team and reports the caller as its owner', async () => {
|
||||
const res = await call('POST', '/teams', env.users.user.token, {
|
||||
name: 'Acme Design',
|
||||
handle: randomHandle(),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body).toMatchObject({ name: 'Acme Design', is_owner: true });
|
||||
expect(body.uid).toMatch(/^[0-9a-f-]{36}$/u);
|
||||
// `id` is internal and must not reach the wire.
|
||||
expect(body).not.toHaveProperty('id');
|
||||
});
|
||||
|
||||
it('returns 404, not 403, to a non-member', async () => {
|
||||
const { team } = await makeTeam();
|
||||
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
// 403 would confirm the team exists.
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses every administrative route to a member who is not the owner', async () => {
|
||||
const { team, memberUsername } = await makeTeam();
|
||||
// An activated seat: provisioning leaves the email unconfirmed, which
|
||||
// `requireVerified` rejects, so an unactivated one cannot call at all.
|
||||
const provisioned = await env.server.stores.user.getByUsername(
|
||||
memberUsername,
|
||||
);
|
||||
await env.server.stores.user.update(provisioned!.id, {
|
||||
email_confirmed: 1,
|
||||
requires_email_confirmation: 0,
|
||||
requires_password_change: 0,
|
||||
});
|
||||
const seat = await env.server.stores.user.getByUsername(memberUsername);
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
seat!,
|
||||
);
|
||||
|
||||
for (const [method, path] of [
|
||||
['PUT', `/teams/${team.uid}`],
|
||||
['DELETE', `/teams/${team.uid}`],
|
||||
['POST', `/teams/${team.uid}/members`],
|
||||
['GET', `/teams/${team.uid}/audit`],
|
||||
] as const) {
|
||||
const res = await call(
|
||||
method,
|
||||
path,
|
||||
token,
|
||||
method === 'GET' ? undefined : { name: 'nope' },
|
||||
);
|
||||
expect(res.status, `${method} ${path}`).toBe(403);
|
||||
}
|
||||
|
||||
// Still a member, so reads it is entitled to still work.
|
||||
const readable = await call('GET', `/teams/${team.uid}`, token);
|
||||
expect(readable.status).toBe(200);
|
||||
});
|
||||
|
||||
// -- the org_owned guard ------------------------------------------
|
||||
|
||||
it('refuses the team owner as the target of a member route', async () => {
|
||||
const { team } = await makeTeam();
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members/${env.users.user.username}/disable`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('deletes a seat over HTTP only once it is disabled', async () => {
|
||||
const { team, memberUsername } = await makeTeam();
|
||||
const path = `/teams/${team.uid}/members/${memberUsername}`;
|
||||
|
||||
const live = await call('DELETE', path, env.users.user.token);
|
||||
expect(live.status).toBe(409);
|
||||
expect(await live.json()).toMatchObject({
|
||||
code: 'account_must_be_disabled_first',
|
||||
});
|
||||
|
||||
await call('POST', `${path}/disable`, env.users.user.token);
|
||||
const gone = await call('DELETE', path, env.users.user.token);
|
||||
|
||||
expect(gone.status).toBe(200);
|
||||
expect(
|
||||
await env.server.stores.user.getByUsername(memberUsername),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
// -- the directory ------------------------------------------------
|
||||
|
||||
it('keeps the directory shut until the team opts in', async () => {
|
||||
const { team } = await makeTeam();
|
||||
|
||||
const before = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/directory`,
|
||||
env.users.user.token,
|
||||
);
|
||||
// 404, not 403: whether this is on is not an app's to probe for.
|
||||
expect(before.status).toBe(404);
|
||||
|
||||
const on = await call('PUT', `/teams/${team.uid}`, env.users.user.token, {
|
||||
directory_enabled: true,
|
||||
});
|
||||
expect(on.status).toBe(200);
|
||||
expect(await on.json()).toMatchObject({ directory_enabled: true });
|
||||
|
||||
const after = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/directory`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(after.status).toBe(200);
|
||||
const body = (await after.json()) as { items: { username: string }[] };
|
||||
expect(body.items.some((m) => m.username === env.users.user.username)).toBe(true);
|
||||
|
||||
// A scoped token was minted for one thing; a roster is not it.
|
||||
const scoped = await makeScopedToken(env.users.user);
|
||||
const narrow = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/directory`,
|
||||
typeof scoped === 'string' ? scoped : scoped.token,
|
||||
);
|
||||
expect(narrow.status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses the directory to someone outside the team', async () => {
|
||||
const { team } = await makeTeam();
|
||||
await call('PUT', `/teams/${team.uid}`, env.users.user.token, {
|
||||
directory_enabled: true,
|
||||
});
|
||||
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/directory`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// -- app actors, gated by the same opt-in as the directory --------
|
||||
//
|
||||
// Built through the service, not the wire: these routes share a
|
||||
// rate-limit budget with the mutation tests above, and every HTTP
|
||||
// create/update here would eat into that same window.
|
||||
|
||||
/** A team owned by `user`, created and toggled through the service. */
|
||||
const makeServiceTeam = async () => {
|
||||
const owner = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!;
|
||||
const team = await env.server.services.team.createTeam(owner.id, {
|
||||
name: 'Acme',
|
||||
handle: randomHandle(),
|
||||
});
|
||||
return { team, ownerId: owner.id };
|
||||
};
|
||||
|
||||
it('omits a team from an app token until the directory opts in', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const before = await call('GET', '/teams', appToken);
|
||||
expect(before.status).toBe(200);
|
||||
const beforeBody = (await before.json()) as { items: { uid: string }[] };
|
||||
expect(beforeBody.items.some((t) => t.uid === team.uid)).toBe(false);
|
||||
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const after = await call('GET', '/teams', appToken);
|
||||
expect(after.status).toBe(200);
|
||||
const afterBody = (await after.json()) as { items: { uid: string }[] };
|
||||
expect(afterBody.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not count a team it hides from an app token', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const res = await call('GET', '/teams?includeTotal=true', appToken);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
items: { uid: string }[];
|
||||
total: number;
|
||||
};
|
||||
expect(body.items.some((t) => t.uid === team.uid)).toBe(false);
|
||||
// A count over rows the page withholds reports the hidden team.
|
||||
expect(body.total).toBe(body.items.length);
|
||||
});
|
||||
|
||||
it('still lists a team to its owner session with the directory off', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call('GET', '/teams', env.users.user.token);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { items: { uid: string }[] };
|
||||
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('lists a team to a full-access API token with the directory off', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call('GET', '/teams', env.users.user.apiToken);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { items: { uid: string }[] };
|
||||
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('404s /members to an app token until the directory opts in, then gives usernames only', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const username = `svc_${Math.random().toString(36).slice(2, 9)}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, ownerId, {
|
||||
username,
|
||||
});
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const before = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(before.status).toBe(404);
|
||||
expect(await before.json()).toMatchObject({ code: 'team_not_found' });
|
||||
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const after = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(after.status).toBe(200);
|
||||
const body = (await after.json()) as {
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
expect(body.items.length).toBeGreaterThan(0);
|
||||
// An app gets username only — no org_owned, created_at, or uuid.
|
||||
for (const m of body.items) expect(Object.keys(m)).toEqual(['username']);
|
||||
});
|
||||
|
||||
it('still refuses an app token on GET /teams/:uid', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const res = await call('GET', `/teams/${team.uid}`, appToken);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
it('excludes a never-activated or suspended seat from an app`s view of /members', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const neverActivated = `svc_na_${Math.random().toString(36).slice(2, 9)}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, ownerId, {
|
||||
username: neverActivated,
|
||||
});
|
||||
const suspendedUsername = `svc_su_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const suspended = await env.server.services.team.provisionAccount(
|
||||
team.uid,
|
||||
ownerId,
|
||||
{ username: suspendedUsername },
|
||||
);
|
||||
await env.server.services.team.disableMember(
|
||||
team.uid,
|
||||
ownerId,
|
||||
suspended.userId,
|
||||
);
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
const appView = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(appView.status).toBe(200);
|
||||
const appItems = (
|
||||
(await appView.json()) as { items: Record<string, unknown>[] }
|
||||
).items;
|
||||
for (const m of appItems) expect(Object.keys(m)).toEqual(['username']);
|
||||
const appUsernames = appItems.map((m) => m.username);
|
||||
expect(appUsernames).not.toContain(neverActivated);
|
||||
expect(appUsernames).not.toContain(suspendedUsername);
|
||||
// The owner's own seat is active, so it still shows.
|
||||
expect(appUsernames).toContain(env.users.user.username);
|
||||
|
||||
// The owner's own session sees every seat regardless of status.
|
||||
const ownerView = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(ownerView.status).toBe(200);
|
||||
const ownerUsernames = (
|
||||
(await ownerView.json()) as { items: { username: string }[] }
|
||||
).items.map((m) => m.username);
|
||||
expect(ownerUsernames).toContain(neverActivated);
|
||||
expect(ownerUsernames).toContain(suspendedUsername);
|
||||
});
|
||||
|
||||
it('gives a full-access API token the owner`s uuid on /members', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.apiToken,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
items: { username: string; uuid?: string }[];
|
||||
};
|
||||
const owner = body.items.find(
|
||||
(m) => m.username === env.users.user.username,
|
||||
);
|
||||
expect(owner?.uuid).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('refuses a scoped access token on GET /teams and /members', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const scoped = await makeScopedToken(env.users.user);
|
||||
|
||||
const teamsRes = await call('GET', '/teams', scoped);
|
||||
expect(teamsRes.status).toBe(403);
|
||||
expect(await teamsRes.json()).toMatchObject({ code: 'forbidden' });
|
||||
|
||||
const membersRes = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
scoped,
|
||||
);
|
||||
expect(membersRes.status).toBe(403);
|
||||
expect(await membersRes.json()).toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
it('lists members with org_owned distinguishing the owner', async () => {
|
||||
const { team, memberUsername } = await makeTeam();
|
||||
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
items: { username: string; org_owned: boolean }[];
|
||||
};
|
||||
const owner = body.items.find(
|
||||
(m) => m.username === env.users.user.username,
|
||||
);
|
||||
const member = body.items.find((m) => m.username === memberUsername);
|
||||
expect(owner?.org_owned).toBe(false);
|
||||
expect(member?.org_owned).toBe(true);
|
||||
});
|
||||
|
||||
it('gives seat uuids to the owner and to nobody else', async () => {
|
||||
// Built through the service, not the wire: provisioning shares a
|
||||
// rate-limit budget with the seat tests below, and one more HTTP
|
||||
// provision here 429s them.
|
||||
const owner = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!;
|
||||
const slug0 = Math.random().toString(36).slice(2, 9);
|
||||
const team = await env.server.services.team.createTeam(owner.id, {
|
||||
name: 'Acme',
|
||||
handle: `uuidt-${slug0}`,
|
||||
});
|
||||
const memberUsername = `seat_${slug0}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, owner.id, {
|
||||
username: memberUsername,
|
||||
});
|
||||
|
||||
const owned = (await (
|
||||
await call('GET', `/teams/${team.uid}/members`, env.users.user.token)
|
||||
).json()) as { items: { username: string; uuid?: string }[] };
|
||||
const seat = owned.items.find((m) => m.username === memberUsername);
|
||||
// Billing keys a seat's plan on this, so the owner cannot act without it.
|
||||
expect(seat?.uuid).toEqual(expect.any(String));
|
||||
|
||||
// A throwaway member, not the shared fixture: joining a team is
|
||||
// permanent and would follow `other` into every later test.
|
||||
const slug = Math.random().toString(36).slice(2, 9);
|
||||
const joiner = await env.server.stores.user.create({
|
||||
username: `joiner_${slug}`,
|
||||
uuid: crypto.randomUUID(),
|
||||
password: 'hashed',
|
||||
email: `joiner_${slug}@test.local`,
|
||||
});
|
||||
await env.server.stores.team.addMember(team.uid, joiner.id, {
|
||||
orgOwned: false,
|
||||
});
|
||||
const { token } =
|
||||
await env.server.services.auth.createSessionToken(joiner);
|
||||
|
||||
const seen = (await (
|
||||
await call('GET', `/teams/${team.uid}/members`, token)
|
||||
).json()) as { items: { uuid?: string }[] };
|
||||
expect(seen.items.length).toBeGreaterThan(0);
|
||||
for (const m of seen.items) expect(m.uuid).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- provisioning over the wire -----------------------------------
|
||||
|
||||
it('never returns the activation link to the administrator', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const username = `secret_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.token,
|
||||
{ username, email: `${username}@test.local` },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const raw = JSON.stringify(await res.json());
|
||||
// Emailed to the member; returning it would let an admin use it.
|
||||
expect(raw).not.toContain('set-new-password');
|
||||
expect(raw).not.toContain('token');
|
||||
});
|
||||
|
||||
it('refuses a taken username and offers alternatives', async () => {
|
||||
const { team } = await makeTeam();
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.token,
|
||||
{
|
||||
username: env.users.other.username,
|
||||
email: '[email protected]',
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
const body = (await res.json()) as {
|
||||
suggestions?: string[];
|
||||
fields?: { suggestions?: string[] };
|
||||
};
|
||||
const suggestions = body.suggestions ?? body.fields?.suggestions ?? [];
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions).not.toContain(env.users.other.username);
|
||||
});
|
||||
|
||||
// -- audit --------------------------------------------------------
|
||||
|
||||
it('exposes the team audit to the team owner only', async () => {
|
||||
const { team } = await makeTeam();
|
||||
|
||||
const mine = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/audit`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(mine.status).toBe(200);
|
||||
const body = (await mine.json()) as { items: { action: string }[] };
|
||||
expect(body.items.map((e) => e.action)).toContain('provision');
|
||||
|
||||
const theirs = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/audit`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(theirs.status).toBe(404);
|
||||
});
|
||||
|
||||
// -- sharing with a team -------------------------------------
|
||||
|
||||
it('names the team it shared with instead of an empty recipient', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const file = `/${env.users.user.username}/Documents/label-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}.txt`;
|
||||
const written = await call('POST', '/fs/write', env.users.user.token, {
|
||||
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
|
||||
fileContent: 'abc',
|
||||
});
|
||||
expect(written.status).toBe(200);
|
||||
|
||||
const shared = await call('POST', '/share', env.users.user.token, {
|
||||
recipients: [{ team: team.uid }],
|
||||
items: [file],
|
||||
mode: 'read',
|
||||
});
|
||||
expect(shared.status).toBe(200);
|
||||
const body = (await shared.json()) as {
|
||||
results: { recipient: string; status: string }[];
|
||||
};
|
||||
|
||||
expect(body.results[0].status).toBe('success');
|
||||
// Echoes the identifier the caller named; '' would leave a client with
|
||||
// nothing to render or to match its request against.
|
||||
expect(body.results[0].recipient).toBe(team.uid);
|
||||
});
|
||||
|
||||
it('shares with two teams rather than collapsing them into one', async () => {
|
||||
const second = await call('POST', '/teams', env.users.user.token, {
|
||||
name: 'Second',
|
||||
handle: randomHandle(),
|
||||
});
|
||||
expect(second.status).toBe(200);
|
||||
const teamB = (await second.json()) as { uid: string };
|
||||
const { team: teamA } = await makeTeam();
|
||||
|
||||
const file = `/${env.users.user.username}/Documents/two-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}.txt`;
|
||||
const written = await call('POST', '/fs/write', env.users.user.token, {
|
||||
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
|
||||
fileContent: 'abc',
|
||||
});
|
||||
const fileUid = ((await written.json()) as { fsEntry: { uid: string } })
|
||||
.fsEntry.uid;
|
||||
|
||||
const shared = await call('POST', '/share', env.users.user.token, {
|
||||
recipients: [{ team: teamA.uid }, { team: teamB.uid }],
|
||||
items: [file],
|
||||
mode: 'read',
|
||||
});
|
||||
expect(shared.status).toBe(200);
|
||||
const body = (await shared.json()) as {
|
||||
results: { recipient: string; status: string }[];
|
||||
};
|
||||
|
||||
expect(body.results).toHaveLength(2);
|
||||
expect(body.results.every((r) => r.status === 'success')).toBe(true);
|
||||
|
||||
// The response shape alone proves nothing: `results` is built from the
|
||||
// request's pairs, so a collapsed pair still reports two successes with
|
||||
// the right labels. Only the shares that exist afterwards show it.
|
||||
const mine = await call(
|
||||
'GET',
|
||||
'/share/shared-by-me?limit=100',
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(mine.status).toBe(200);
|
||||
const listing = (await mine.json()) as {
|
||||
items: { uid_entry?: string; holder_team?: { uid: string } }[];
|
||||
};
|
||||
const holders = listing.items
|
||||
.filter((i) => i.uid_entry === fileUid)
|
||||
.map((i) => i.holder_team?.uid)
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
expect(holders).toEqual([teamA.uid, teamB.uid].sort());
|
||||
});
|
||||
|
||||
it('names the team by handle when the caller shared by handle', async () => {
|
||||
const handle = randomHandle();
|
||||
const res = await call('POST', '/teams', env.users.user.token, {
|
||||
name: 'Byhandle',
|
||||
handle,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const file = `/${env.users.user.username}/Documents/handle-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}.txt`;
|
||||
await call('POST', '/fs/write', env.users.user.token, {
|
||||
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
|
||||
fileContent: 'abc',
|
||||
});
|
||||
|
||||
const shared = await call('POST', '/share', env.users.user.token, {
|
||||
recipients: [{ teamHandle: handle }],
|
||||
items: [file],
|
||||
mode: 'read',
|
||||
});
|
||||
expect(shared.status).toBe(200);
|
||||
const body = (await shared.json()) as {
|
||||
results: { recipient: string }[];
|
||||
};
|
||||
expect(body.results[0].recipient).toBe(handle);
|
||||
});
|
||||
|
||||
it('gives a non-member 404 on the member view, not an empty page', async () => {
|
||||
const { team } = await makeTeam();
|
||||
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/audit/me`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// -- the forced-change gate ---------------------------------------
|
||||
|
||||
/**
|
||||
* A seat that has signed in on its temporary password. Email confirmation
|
||||
* is cleared first so the gate under test is the one that answers.
|
||||
*/
|
||||
const signedInSeat = async (teamUid: string) => {
|
||||
const username = `seat_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const res = await call(
|
||||
'POST',
|
||||
`/teams/${teamUid}/members`,
|
||||
env.users.user.token,
|
||||
{ username, email: `${username}@test.local` },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const { temporary_password: password } = (await res.json()) as {
|
||||
temporary_password: string;
|
||||
};
|
||||
|
||||
const row = (await env.server.stores.user.getByUsername(username))!;
|
||||
await env.server.stores.user.update(row.id, {
|
||||
requires_email_confirmation: false,
|
||||
email_confirmed: true,
|
||||
});
|
||||
await env.server.stores.user.invalidateById(row.id);
|
||||
|
||||
const fresh = (await env.server.stores.user.getById(row.id))!;
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
fresh,
|
||||
{ user_agent: 'puter-test-seat' },
|
||||
);
|
||||
return { username, password, userId: row.id, token };
|
||||
};
|
||||
|
||||
/**
|
||||
* Cookie-credentialed on the GUI origin, as the user-protected gate insists.
|
||||
* Sent with the seat's own user agent: the session touch otherwise rewrites
|
||||
* it to Node's once a second boundary passes.
|
||||
*/
|
||||
const changePassword = (token: string, password: string, next: string) =>
|
||||
fetch(new URL('/user-protected/change-password', env.origin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'user-agent': 'puter-test-seat',
|
||||
authorization: `Bearer ${token}`,
|
||||
cookie: `puter_auth_token=${token}`,
|
||||
},
|
||||
body: JSON.stringify({ password, new_pass: next }),
|
||||
});
|
||||
|
||||
it('signs the seat in but refuses every authenticated route after that', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
|
||||
const res = await call('GET', '/teams', seat.token);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.text()).toContain('password_change_required');
|
||||
});
|
||||
|
||||
it('leaves change-password reachable, since it is what clears the gate', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
|
||||
const changed = await changePassword(
|
||||
seat.token,
|
||||
seat.password,
|
||||
'chosen-by-the-member',
|
||||
);
|
||||
expect(changed.status).toBe(200);
|
||||
|
||||
const row = await env.server.stores.user.getByProperty(
|
||||
'id',
|
||||
seat.userId,
|
||||
{ force: true },
|
||||
);
|
||||
expect(Number(row?.requires_password_change)).toBe(0);
|
||||
expect(row?.temp_password_expires_at ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('admits the seat to the product once it has chosen a password', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
expect(
|
||||
(await changePassword(seat.token, seat.password, 'my-own-password'))
|
||||
.status,
|
||||
).toBe(200);
|
||||
|
||||
// The session that changed the password is the one that survives.
|
||||
const res = await call('GET', '/teams', seat.token);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('closes re-issue behind a seat that has activated', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
await changePassword(seat.token, seat.password, 'a-password-of-mine');
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members/${seat.username}/activation`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('shows the member the reset and their own sign-in, and nothing else', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
await changePassword(seat.token, seat.password, 'chosen-once-already');
|
||||
|
||||
const reset = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members/${seat.username}/password-reset`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(reset.status).toBe(200);
|
||||
const { temporary_password: issued } = (await reset.json()) as {
|
||||
temporary_password: string;
|
||||
};
|
||||
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
(await env.server.stores.user.getById(seat.userId))!,
|
||||
{ user_agent: 'puter-test-seat' },
|
||||
);
|
||||
const mine = await call('GET', `/teams/${team.uid}/audit/me`, token);
|
||||
// The seat owes a password change again, so its own view is all it reaches.
|
||||
expect(mine.status).toBe(403);
|
||||
|
||||
const admin = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/audit`,
|
||||
env.users.user.token,
|
||||
);
|
||||
const body = (await admin.json()) as { items: { action: string }[] };
|
||||
expect(body.items.map((e) => e.action)).toContain(
|
||||
'reset_member_password',
|
||||
);
|
||||
expect(JSON.stringify(body)).not.toContain(issued);
|
||||
});
|
||||
|
||||
it('shows an activated seat its own reset and sign-in over the wire', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
await changePassword(seat.token, seat.password, 'the-one-i-picked');
|
||||
|
||||
const res = await call('GET', `/teams/${team.uid}/audit/me`, seat.token);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
items: {
|
||||
action: string;
|
||||
username: string | null;
|
||||
ip: string | null;
|
||||
user_agent: string | null;
|
||||
}[];
|
||||
};
|
||||
expect(body.items.map((e) => e.action)).toContain('activate');
|
||||
|
||||
const tell = body.items.find((e) => e.action === 'sign_in');
|
||||
expect(tell?.username).toBe(seat.username);
|
||||
expect(tell?.user_agent).toBe('puter-test-seat');
|
||||
// Only their own; the team owner's entries are not theirs to read.
|
||||
expect(
|
||||
body.items.every((e) => e.username === seat.username),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a temporary password that was never used in time', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
await env.server.stores.user.update(seat.userId, {
|
||||
temp_password_expires_at: Math.floor(Date.now() / 1000) - 1,
|
||||
});
|
||||
await env.server.stores.user.invalidateById(seat.userId);
|
||||
|
||||
const res = await fetch(new URL('/login', env.origin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: seat.username,
|
||||
password: seat.password,
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.text()).toContain('temporary_password_expired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('team endpoints with teams_enabled off', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({ teams_enabled: false } as IConfig);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
it('404s every team route, so no team code is reachable', async () => {
|
||||
// Real team: 200 with the flag on, so a 404 means no route.
|
||||
const owner = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
const team = await env.server.stores.team.create({
|
||||
ownerUserId: owner!.id,
|
||||
name: 'Unreachable',
|
||||
handle: `off-${Math.random().toString(36).slice(2, 8)}`,
|
||||
});
|
||||
await env.server.stores.team.addMember(team.uid, owner!.id, {
|
||||
orgOwned: false,
|
||||
});
|
||||
|
||||
const paths: [string, string][] = [
|
||||
['POST', '/teams'],
|
||||
['GET', '/teams'],
|
||||
['GET', `/teams/${team.uid}`],
|
||||
['PUT', `/teams/${team.uid}`],
|
||||
['DELETE', `/teams/${team.uid}`],
|
||||
['GET', `/teams/${team.uid}/members`],
|
||||
['POST', `/teams/${team.uid}/members`],
|
||||
['GET', `/teams/${team.uid}/audit`],
|
||||
['GET', `/teams/${team.uid}/audit/me`],
|
||||
];
|
||||
|
||||
for (const [method, path] of paths) {
|
||||
const res = await fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${env.users.user.token}`,
|
||||
},
|
||||
...(method === 'POST' || method === 'PUT'
|
||||
? { body: JSON.stringify({ name: 'x' }) }
|
||||
: {}),
|
||||
});
|
||||
expect(res.status, `${method} ${path}`).toBe(404);
|
||||
|
||||
// Ours would carry a team legacyCode; the framework's does not.
|
||||
const body = await res.text();
|
||||
expect(body, `${method} ${path}`).not.toContain('team_not_found');
|
||||
expect(body, `${method} ${path}`).not.toContain(
|
||||
'not_the_team_owner',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('registers no team routes at all with the flag off', async () => {
|
||||
// The controller exists and is constructed; only its routes are absent.
|
||||
const controller = env.server.controllers.team as unknown as {
|
||||
isEnabled?: () => boolean;
|
||||
};
|
||||
expect(controller).toBeTruthy();
|
||||
expect(controller.isEnabled?.()).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the schema inert rather than absent', async () => {
|
||||
// The flag gates reachability, not DDL.
|
||||
await expect(
|
||||
env.server.stores.team.getByHandle('no-such-team'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,565 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
} from '../../core/http/decorators.js';
|
||||
import { isAccountContext } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { TeamRow } from '../../stores/team/TeamStore.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
/** Mirrors ShareController's dual-window shape. */
|
||||
const TEAM_LIMIT = [
|
||||
{ scope: 'team:mutate', limit: 60, window: 60_000, key: 'user' as const },
|
||||
{
|
||||
scope: 'team:mutate-daily',
|
||||
limit: 500,
|
||||
window: 24 * 60 * 60_000,
|
||||
key: 'user' as const,
|
||||
},
|
||||
];
|
||||
|
||||
/** Tighter than TEAM_LIMIT: a reset hands the admin a working credential. */
|
||||
const TEAM_RESET_LIMIT = [
|
||||
...TEAM_LIMIT,
|
||||
{
|
||||
scope: 'team:reset-password',
|
||||
limit: 20,
|
||||
window: 24 * 60 * 60_000,
|
||||
key: 'user' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const TEAM_READ_LIMIT = {
|
||||
scope: 'team:read',
|
||||
limit: 600,
|
||||
window: 60_000,
|
||||
key: 'user' as const,
|
||||
};
|
||||
|
||||
/** What a team looks like on the wire. `id` stays internal. */
|
||||
const toClientTeam = (team: TeamRow, isOwner: boolean) => ({
|
||||
uid: team.uid,
|
||||
name: team.name,
|
||||
handle: team.handle,
|
||||
is_owner: isOwner,
|
||||
created_at: team.created_at,
|
||||
directory_enabled: Number(team.directory_enabled) === 1,
|
||||
require_2fa: Number(team.require_2fa) === 1,
|
||||
});
|
||||
|
||||
@Controller('/teams')
|
||||
export class TeamController extends PuterController {
|
||||
// Most routes gate with `requireUserActor`; `listTeams` and `listMembers`
|
||||
// use `requireAuth` so an app can read a team whose owner opted into the
|
||||
// directory (see `directory_enabled`).
|
||||
|
||||
/** Off means `/teams` 404s and no team route is registered at all. */
|
||||
isEnabled(): boolean {
|
||||
return this.config.teams_enabled === true;
|
||||
}
|
||||
|
||||
@Post('', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async createTeam(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.#requireTeamsAvailable(req, userId);
|
||||
const body = this.#body(req);
|
||||
|
||||
const team = await this.services.team.createTeam(userId, {
|
||||
name: this.#requireString(body.name, 'name'),
|
||||
handle:
|
||||
body.handle === undefined || body.handle === null
|
||||
? null
|
||||
: this.#requireString(body.handle, 'handle'),
|
||||
});
|
||||
res.json(toClientTeam(team, true));
|
||||
}
|
||||
|
||||
@Get('', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listTeams(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.#requireTeamsAvailable(req, userId);
|
||||
// In SQL, or cursor and total describe teams the app cannot see.
|
||||
const page = await this.stores.team.listTeamsForUser(userId, {
|
||||
limit: req.query.limit,
|
||||
cursor:
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
includeTotal: req.query.includeTotal === 'true',
|
||||
openOnly: !isAccountContext(req.actor),
|
||||
});
|
||||
res.json({
|
||||
items: page.items.map((t) =>
|
||||
toClientTeam(t, t.owner_user_id === userId),
|
||||
),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total === undefined ? {} : { total: page.total }),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('/:uid', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async getTeam(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const team = await this.services.team.requireMembership(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
);
|
||||
res.json(toClientTeam(team, team.owner_user_id === userId));
|
||||
}
|
||||
|
||||
@Put('/:uid', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async updateTeam(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.services.team.requireOwner(this.#param(req, 'uid'), userId);
|
||||
const body = this.#body(req);
|
||||
|
||||
const changes: {
|
||||
name?: string;
|
||||
handle?: string | null;
|
||||
directoryEnabled?: boolean;
|
||||
require2fa?: boolean;
|
||||
} = {};
|
||||
if (body.name !== undefined)
|
||||
changes.name = this.#requireString(body.name, 'name');
|
||||
if (body.handle !== undefined)
|
||||
changes.handle = body.handle === null ? null : String(body.handle);
|
||||
if (body.directory_enabled !== undefined)
|
||||
changes.directoryEnabled = body.directory_enabled === true;
|
||||
if (body.require_2fa !== undefined)
|
||||
changes.require2fa = body.require_2fa === true;
|
||||
|
||||
// Through the service: turning the directory on writes an audit row.
|
||||
const team = await this.services.team.updateTeam(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
changes,
|
||||
);
|
||||
res.json(toClientTeam(team, true));
|
||||
}
|
||||
|
||||
@Delete('/:uid', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async deleteTeam(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.services.team.deleteTeam(this.#param(req, 'uid'), userId);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// -- Members ------------------------------------------------------
|
||||
|
||||
@Get('/:uid/members', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listMembers(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const team = await this.services.team.requireMembership(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
);
|
||||
const accountContext = isAccountContext(req.actor);
|
||||
// Same consent as the directory: an app only reads a team that opted in.
|
||||
if (!accountContext) this.services.team.assertDirectoryOpen(team);
|
||||
|
||||
// uuid only when the team owner calls with their own session or API
|
||||
// token; an app needing a stable id uses `listDirectory()` instead.
|
||||
const showUuid = accountContext && team.owner_user_id === userId;
|
||||
|
||||
const page = await this.stores.team.listMembers(
|
||||
this.#param(req, 'uid'),
|
||||
{
|
||||
limit: req.query.limit,
|
||||
cursor:
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
// An app's view matches the directory's: active seats only.
|
||||
activeOnly: !accountContext,
|
||||
},
|
||||
);
|
||||
// An app only ever gets a username; org_owned/created_at are account-only.
|
||||
res.json({
|
||||
items: page.items.map((m) =>
|
||||
accountContext
|
||||
? {
|
||||
username: m.username,
|
||||
org_owned: Number(m.org_owned) === 1,
|
||||
created_at: m.created_at,
|
||||
...(showUuid ? { uuid: m.uuid } : {}),
|
||||
}
|
||||
: { username: m.username },
|
||||
),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** App actors too, once opted in; carries the `uuid` `/members` hides. */
|
||||
@Get('/:uid/directory', {
|
||||
subdomain: 'api',
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listDirectory(req: Request, res: Response): Promise<void> {
|
||||
// The membership is the person's, never the app's.
|
||||
const userId = this.#requireUserId(req);
|
||||
// A scoped token holds only what it was minted for; a roster is not that.
|
||||
const token = req.actor?.accessToken;
|
||||
if (token && token.fullAccess !== true) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This endpoint is not available to scoped access tokens',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
const page = await this.services.team.listDirectory(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
{
|
||||
limit: req.query.limit,
|
||||
cursor:
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
res.json(page);
|
||||
}
|
||||
|
||||
@Post('/:uid/members', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async createMember(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority before shape, or a stranger learns if their body parsed.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
|
||||
const body = this.#body(req);
|
||||
const result = await this.services.team.provisionAccount(
|
||||
uid,
|
||||
userId,
|
||||
{
|
||||
username: this.#requireString(body.username, 'username'),
|
||||
email: this.#optionalString(body.email, 'email'),
|
||||
},
|
||||
// The owner's request is what the signup gate gets to judge.
|
||||
{ req, fingerprint: req.deviceFingerprint ?? null },
|
||||
);
|
||||
// Shown once; the admin delivers it out of band.
|
||||
res.json({
|
||||
username: result.username,
|
||||
temporary_password: result.temporaryPassword,
|
||||
});
|
||||
}
|
||||
|
||||
// Same budget as a reset: both hand the administrator a working credential,
|
||||
// and a reset re-arms the flag this route needs.
|
||||
@Post('/:uid/members/:username/activation', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_RESET_LIMIT,
|
||||
})
|
||||
async reissueCredential(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
const { temporaryPassword } =
|
||||
await this.services.team.reissueCredential(uid, userId, target);
|
||||
// Shown once for the admin to deliver out of band.
|
||||
res.json({ temporary_password: temporaryPassword });
|
||||
}
|
||||
|
||||
@Post('/:uid/members/:username/password-reset', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_RESET_LIMIT,
|
||||
})
|
||||
async resetMemberPassword(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
const { temporaryPassword } =
|
||||
await this.services.team.resetMemberPassword(uid, userId, target);
|
||||
// Shown once; it is not retrievable afterwards.
|
||||
res.json({ temporary_password: temporaryPassword });
|
||||
}
|
||||
|
||||
// The gate closes the seat's own route to fix it, so the owner must.
|
||||
@Post('/:uid/members/:username/email', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async updateSeatEmail(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.updateSeatEmail(
|
||||
uid,
|
||||
userId,
|
||||
target,
|
||||
(req.body as { email?: unknown } | undefined)?.email ?? null,
|
||||
);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// Same budget as a password reset: both are a step toward the account.
|
||||
@Post('/:uid/members/:username/2fa-reset', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_RESET_LIMIT,
|
||||
})
|
||||
async resetMemberTwoFactor(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.resetMemberTwoFactor(uid, userId, target);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
@Post('/:uid/members/:username/disable', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async disableMember(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.disableMember(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
target,
|
||||
);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
@Post('/:uid/members/:username/enable', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async enableMember(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.enableMember(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
target,
|
||||
);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
@Delete('/:uid/members/:username', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async deleteMember(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first (anti-oracle); deleted team included, see the service.
|
||||
await this.services.team.requireOwnedTeam(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.deleteMember(uid, userId, target);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// -- Audit --------------------------------------------------------
|
||||
|
||||
@Get('/:uid/audit', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listAudit(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
res.json(
|
||||
await this.services.team.listAudit(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
this.#pageOpts(req),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('/:uid/audit/me', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listOwnAudit(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
res.json(
|
||||
await this.services.team.listOwnAudit(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
this.#pageOpts(req),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Helpers ------------------------------------------------------
|
||||
|
||||
/** `limit` and `cursor` as doc/pagination.md defines them. */
|
||||
#pageOpts(req: Request): { limit?: unknown; cursor?: string } {
|
||||
return {
|
||||
limit: req.query.limit,
|
||||
cursor:
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The domain-allowlist gate, on the two routes that enter the feature. Same
|
||||
* 404 as a teams-off deployment; other routes bound by membership.
|
||||
*/
|
||||
async #requireTeamsAvailable(req: Request, userId: number): Promise<void> {
|
||||
const email = (
|
||||
req.actor as { user?: { email?: string | null } } | undefined
|
||||
)?.user?.email;
|
||||
if (await this.services.team.teamsAvailableTo(userId, email)) return;
|
||||
throw new HttpError(404, 'Not found', { legacyCode: 'not_found' });
|
||||
}
|
||||
|
||||
#requireUserId(req: Request): number {
|
||||
const id = (req.actor as { user?: { id?: number } } | undefined)?.user
|
||||
?.id;
|
||||
if (!id)
|
||||
throw new HttpError(401, 'User required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Express types a param as `string | string[]`; routes here take one. */
|
||||
#param(req: Request, name: string): string {
|
||||
const value = req.params[name];
|
||||
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '');
|
||||
}
|
||||
|
||||
#body(req: Request): Record<string, unknown> {
|
||||
return (req.body ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Absent or empty means "not given"; a wrong type is still a 400. */
|
||||
#optionalString(value: unknown, field: string): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value !== 'string') {
|
||||
throw new HttpError(400, `${field} must be a string`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return value.trim() === '' ? null : value;
|
||||
}
|
||||
|
||||
#requireString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new HttpError(400, `${field} is required`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
#notFound(): HttpError {
|
||||
return new HttpError(404, 'Team not found', {
|
||||
legacyCode: 'team_not_found',
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves `:username` to an id; the service decides whether it may act. */
|
||||
async #requireTargetUserId(req: Request): Promise<number> {
|
||||
const user = await this.stores.user.getByUsername(
|
||||
this.#param(req, 'username'),
|
||||
);
|
||||
if (!user)
|
||||
throw new HttpError(404, 'Not an account of this team', {
|
||||
legacyCode: 'not_an_org_account',
|
||||
});
|
||||
return user.id;
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
/**
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A team manages accounts and cannot read them. Nobody writes that grant
|
||||
* deliberately, but a new implicator or a widened actor would create it and no
|
||||
* other test would fail. Asserts outcomes, never that an implicator is absent.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import type { IConfig } from '../../types';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
describe('a team cannot read its members data', () => {
|
||||
let env: PuterTestEnv;
|
||||
let teamUid: string;
|
||||
let memberUsername: string;
|
||||
let memberFile: string;
|
||||
let ownerFile: string;
|
||||
let memberToken: string;
|
||||
|
||||
/** Written directly: these tests are about reading, not about writing. */
|
||||
const makeFile = async (username: string, label: string) => {
|
||||
const uid = crypto.randomUUID();
|
||||
const name = `${label}-${uid.slice(0, 8)}.txt`;
|
||||
const path = `/${username}/${name}`;
|
||||
const user = await env.server.stores.user.getByUsername(username);
|
||||
await env.server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
uid,
|
||||
name,
|
||||
path,
|
||||
user!.id,
|
||||
// `is_dir` is a real boolean on postgres.
|
||||
env.server.clients.db.booleanValue(false),
|
||||
Math.floor(Date.now() / 1000),
|
||||
],
|
||||
);
|
||||
return path;
|
||||
};
|
||||
|
||||
const stat = (path: string, token: string) =>
|
||||
fetch(new URL('/stat', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
|
||||
const readdir = (path: string, token: string) =>
|
||||
fetch(new URL('/readdir', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
// The cap has its own suite; these tests need many teams.
|
||||
env = await setupPuterTestEnv({
|
||||
teams_enabled: true,
|
||||
max_teams_per_user: 100,
|
||||
} as IConfig);
|
||||
|
||||
// A provisioned account cannot authenticate until it activates.
|
||||
const res = await fetch(new URL('/teams', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${env.users.user.token}`,
|
||||
},
|
||||
body: JSON.stringify({ name: 'Isolation', handle: `iso-${Date.now()}` }),
|
||||
});
|
||||
teamUid = ((await res.json()) as { uid: string }).uid;
|
||||
|
||||
// A real provisioned seat, activated so it can authenticate.
|
||||
memberUsername = `iso_${Math.random().toString(36).slice(2, 9)}`;
|
||||
await env.server.services.team.provisionAccount(
|
||||
teamUid,
|
||||
(await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!.id,
|
||||
{ username: memberUsername, email: `${memberUsername}@test.local` },
|
||||
);
|
||||
const member = await env.server.stores.user.getByUsername(memberUsername);
|
||||
await env.server.stores.user.update(member!.id, {
|
||||
email_confirmed: 1,
|
||||
requires_email_confirmation: 0,
|
||||
requires_password_change: 0,
|
||||
});
|
||||
memberToken = (
|
||||
await env.server.services.auth.createSessionToken(
|
||||
(await env.server.stores.user.getByUsername(memberUsername))!,
|
||||
)
|
||||
).token;
|
||||
|
||||
memberFile = await makeFile(memberUsername, 'member');
|
||||
ownerFile = await makeFile(env.users.user.username, 'owner');
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
// -- refused ------------------------------------------------------
|
||||
|
||||
it('refuses the team owner a members file', async () => {
|
||||
const res = await stat(memberFile, env.users.user.token);
|
||||
// Administering, paying for, and reading an account are three things.
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('refuses the team owner a members home directory', async () => {
|
||||
const res = await readdir(`/${memberUsername}`, env.users.user.token);
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('refuses a full-access token no less than a session', async () => {
|
||||
// `#scanAccessToken` re-scans the issuer as a plain user actor.
|
||||
const res = await stat(memberFile, env.users.user.apiToken);
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('grants no team-wide reach through the team routes', async () => {
|
||||
// There is no route to a member's files or KV, and none should appear.
|
||||
for (const path of [
|
||||
`/teams/${teamUid}/files`,
|
||||
`/teams/${teamUid}/members/${memberUsername}/files`,
|
||||
`/teams/${teamUid}/kv`,
|
||||
]) {
|
||||
const res = await fetch(new URL(path, env.apiOrigin), {
|
||||
headers: { authorization: `Bearer ${env.users.user.token}` },
|
||||
});
|
||||
expect(res.status, path).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
// -- allowed ------------------------------------------------------
|
||||
|
||||
it('lets the team owner read its own files', async () => {
|
||||
const res = await stat(ownerFile, env.users.user.token);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('lets a member read their own files', async () => {
|
||||
const res = await stat(memberFile, memberToken);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('lets an explicit grant through, as an ordinary share', async () => {
|
||||
const member = await env.server.stores.user.getByUsername(
|
||||
memberUsername,
|
||||
);
|
||||
const [entry] = (await env.server.clients.db.read(
|
||||
'SELECT `uuid` FROM `fsentries` WHERE `path` = ?',
|
||||
[memberFile],
|
||||
)) as { uuid: string }[];
|
||||
|
||||
// Only the member's own grant changes, never team authority.
|
||||
const before = await stat(memberFile, env.users.user.token);
|
||||
expect(before.status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
await env.server.services.permission.grantUserUserPermission(
|
||||
makeActor({ user: member!, app: null, accessToken: null } as never),
|
||||
env.users.user.username,
|
||||
`fs:${entry.uuid}:read`,
|
||||
);
|
||||
|
||||
const after = await stat(memberFile, env.users.user.token);
|
||||
expect(after.status).toBe(200);
|
||||
});
|
||||
|
||||
it('disabling a member neither grants nor transfers their files', async () => {
|
||||
const owner = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
const member = await env.server.stores.user.getByUsername(
|
||||
memberUsername,
|
||||
);
|
||||
// A fresh file: the earlier test granted the owner read on `memberFile`.
|
||||
const untouched = await makeFile(memberUsername, 'untouched');
|
||||
|
||||
await env.server.services.team.disableMember(
|
||||
teamUid,
|
||||
owner!.id,
|
||||
member!.id,
|
||||
);
|
||||
|
||||
// The member loses access to their own files...
|
||||
const asMember = await stat(untouched, memberToken);
|
||||
expect(asMember.status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// ...and the team still does not gain it.
|
||||
const asOwner = await stat(untouched, env.users.user.token);
|
||||
expect(asOwner.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// This suite tests basic features of puter webdav. it is not a comprehensive webdav test suite unlike litmus
|
||||
// but rather it performs some common sense checks to ensure that WebDAV support isn't irrevocably broken in puter
|
||||
import { installSeatStub } from '../../testFixtures/seatStub.js';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
@@ -736,9 +737,9 @@ describe('WebDAVController', () => {
|
||||
otp_enabled: 1,
|
||||
otp_secret: secret,
|
||||
});
|
||||
const seat = vi
|
||||
.spyOn(server.stores.team, 'getOrgSeat')
|
||||
.mockResolvedValue({ require_2fa: 1 } as never);
|
||||
// Teams is optional, so the gate's input is stood up directly.
|
||||
installSeatStub(server as never, [id], { require2fa: true });
|
||||
const seat = vi.spyOn(server.stores.team as never, 'getOrgSeat');
|
||||
try {
|
||||
const code = new TOTP({
|
||||
issuer: 'puter.com',
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Team2faLookup } from '../../core/http/middleware/gates.js';
|
||||
import { compare as bcryptCompare } from 'bcrypt';
|
||||
import type { Request, Response } from 'express';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
@@ -288,7 +289,7 @@ export class WebDAVController extends PuterController {
|
||||
// filesystem over the `dav` subdomain, bypassing the gates.
|
||||
assertNotSuspended(actor.user);
|
||||
assertVerifiedAccount(actor.user);
|
||||
await assertTeam2fa(actor.user, this.stores.team);
|
||||
await assertTeam2fa(actor.user, this.stores.team as Team2faLookup);
|
||||
|
||||
// The actor was absent when the auth probe ran, so anything that reads
|
||||
// it off the request or the context — shared-path masking, egress
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
makeActor,
|
||||
SYSTEM_ACTOR,
|
||||
userRelatedActor,
|
||||
viaWorkerOf,
|
||||
type Actor,
|
||||
} from './actor';
|
||||
|
||||
@@ -111,3 +112,28 @@ describe('assertResolvedActor', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viaWorkerOf', () => {
|
||||
const user = { uuid: 'u-1', id: 1 };
|
||||
|
||||
it('reads a worker session or an events handler as server-side', () => {
|
||||
expect(
|
||||
viaWorkerOf(
|
||||
makeActor({ user, session: { uid: 's-1', kind: 'worker' } }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(viaWorkerOf(makeActor({ user, handlerDepth: 1 }))).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a browser session, an app or no actor at all as not', () => {
|
||||
expect(
|
||||
viaWorkerOf(
|
||||
makeActor({ user, session: { uid: 's-1', kind: 'web' } }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(viaWorkerOf(makeActor({ user, app: { uid: 'app-1' } }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(viaWorkerOf(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -239,3 +239,10 @@ export const userRelatedActor = (actor: Actor): Actor => {
|
||||
/** How many events handler runs led to a write by this actor. */
|
||||
export const handlerDepthOf = (actor: Actor | undefined | null): number =>
|
||||
actor?.handlerDepth ?? 0;
|
||||
|
||||
/**
|
||||
* Whether a write by this actor was made by server-side code running as the
|
||||
* account: a worker session, or an events handler.
|
||||
*/
|
||||
export const viaWorkerOf = (actor: Actor | undefined | null): boolean =>
|
||||
actor?.session?.kind === 'worker' || handlerDepthOf(actor) > 0;
|
||||
@@ -67,6 +67,12 @@ export interface KnownContextFields {
|
||||
* receive stops early and is never metered.
|
||||
*/
|
||||
abortSignal: AbortSignal;
|
||||
/**
|
||||
* Aborts the chat driver's upstream request. A client disconnect doesn't
|
||||
* fire it: the upstream bills a request it has accepted either way, so the
|
||||
* driver lets it finish and meters what it reports.
|
||||
*/
|
||||
upstreamSignal: AbortSignal;
|
||||
/**
|
||||
* When true, `ChatCompletionDriver.complete` treats a request-level
|
||||
* upstream 4xx as final instead of falling back to another provider — set
|
||||
@@ -84,6 +90,7 @@ const KNOWN_KEY_FLAGS: Record<keyof KnownContextFields, true> = {
|
||||
requestId: true,
|
||||
driverName: true,
|
||||
abortSignal: true,
|
||||
upstreamSignal: true,
|
||||
strictUpstreamErrors: true,
|
||||
};
|
||||
const KNOWN_KEYS: ReadonlySet<string> = new Set(Object.keys(KNOWN_KEY_FLAGS));
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
createNativeAppStatic,
|
||||
createUserSubdomainNotFound,
|
||||
createWwwRedirect,
|
||||
subdomainLabelOf,
|
||||
type ResolvedClaim,
|
||||
type SubdomainClaim,
|
||||
} from './hostRedirects';
|
||||
@@ -127,6 +128,18 @@ describe('createWwwRedirect', () => {
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves www. alone on a host outside our domains', () => {
|
||||
const req = makeReq({
|
||||
subdomains: ['com', 'example', 'www'],
|
||||
host: 'www.example.com',
|
||||
});
|
||||
(req as Request & { is_custom_domain?: boolean }).is_custom_domain =
|
||||
true;
|
||||
const { out, next } = run(createWwwRedirect(config), req);
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes through when config.domain isn't configured (no target to redirect to)", () => {
|
||||
const { out, next } = run(
|
||||
createWwwRedirect({} as IConfig),
|
||||
@@ -175,7 +188,7 @@ describe('createUserSubdomainNotFound', () => {
|
||||
|
||||
it('passes through reserved subdomains (api, js, native apps, etc.)', () => {
|
||||
// `api`, `js`, `dav`, `docs`, `developer`, `editor`, `pdf`,
|
||||
// `puter-app-icons`, `onlyoffice`, etc. all bypass.
|
||||
// `puter-app-icons`, etc. all pass through.
|
||||
for (const sub of ['api', 'js', 'docs', 'editor', 'puter-app-icons']) {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
@@ -189,6 +202,17 @@ describe('createUserSubdomainNotFound', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('404s an extension-owned subdomain nobody has claimed', () => {
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
makeReq({
|
||||
subdomains: ['com', 'puter', 'onlyoffice'],
|
||||
host: 'onlyoffice.puter.com',
|
||||
}),
|
||||
);
|
||||
expectNotFound(next);
|
||||
});
|
||||
|
||||
it('passes through when no subdomain is present (root)', () => {
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
@@ -640,6 +664,21 @@ describe('createUserSubdomainNotFound with claimed subdomains', () => {
|
||||
expect(args).toEqual([]);
|
||||
});
|
||||
|
||||
it('hands a main-pipeline claim to the rest of the stack, like a reserved name', async () => {
|
||||
const claims = lookup({ office: { mainPipeline: true } });
|
||||
for (const [host, subdomains] of [
|
||||
['office.puter.com', ['office']],
|
||||
['office.puter.com:4100', ['office']],
|
||||
['office.x.puter.com', ['x', 'office']],
|
||||
] as const) {
|
||||
const args = await runClaimed(
|
||||
createUserSubdomainNotFound(config, claims),
|
||||
hostReq(host, [...subdomains]),
|
||||
);
|
||||
expect(args, host).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('looks the claim up on every request', async () => {
|
||||
const claims: Record<string, ResolvedClaim> = {};
|
||||
const middleware = createUserSubdomainNotFound(config, lookup(claims));
|
||||
@@ -650,6 +689,32 @@ describe('createUserSubdomainNotFound with claimed subdomains', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('subdomainLabelOf', () => {
|
||||
it('returns the single label under the domain, ignoring case and port', () => {
|
||||
expect(subdomainLabelOf('shop.puter.com', 'puter.com')).toBe('shop');
|
||||
expect(subdomainLabelOf('SHOP.Puter.com:4100', 'puter.com')).toBe(
|
||||
'shop',
|
||||
);
|
||||
expect(subdomainLabelOf('shop.puter.com', 'Puter.com:443')).toBe(
|
||||
'shop',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for anything but exactly one label under the domain', () => {
|
||||
for (const host of [
|
||||
'puter.com',
|
||||
'.puter.com',
|
||||
'a.shop.puter.com',
|
||||
'shop.puter.com.example.test',
|
||||
'shopputer.com',
|
||||
'shop.other.com',
|
||||
]) {
|
||||
expect(subdomainLabelOf(host, 'puter.com')).toBeUndefined();
|
||||
}
|
||||
expect(subdomainLabelOf('shop.puter.com', '')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addClaimedSubdomain', () => {
|
||||
const claim: SubdomainClaim = { authorize: () => true };
|
||||
|
||||
@@ -677,7 +742,7 @@ describe('addClaimedSubdomain', () => {
|
||||
});
|
||||
|
||||
it('rejects subdomains core already handles', () => {
|
||||
for (const name of ['www', 'api', 'js', 'dav', 'docs', 'onlyoffice']) {
|
||||
for (const name of ['www', 'api', 'js', 'dav', 'docs']) {
|
||||
expect(() => addClaimedSubdomain(new Map(), name, claim)).toThrow(
|
||||
/reserved/,
|
||||
);
|
||||
@@ -706,6 +771,26 @@ describe('addClaimedSubdomain', () => {
|
||||
).toThrow(/invalid checkRoute/);
|
||||
});
|
||||
|
||||
it('records a main-pipeline claim, and refuses one that also carries a gate', () => {
|
||||
const claims = new Map<string, SubdomainClaim>();
|
||||
addClaimedSubdomain(claims, 'onlyoffice', { mainPipeline: true });
|
||||
expect(claims.get('onlyoffice')).toEqual({ mainPipeline: true });
|
||||
expect(() =>
|
||||
addClaimedSubdomain(claims, 'onlyoffice', { mainPipeline: true }),
|
||||
).toThrow(/already claimed/);
|
||||
for (const gate of [
|
||||
{ authorize: () => true },
|
||||
{ checkRoute: () => undefined },
|
||||
]) {
|
||||
expect(() =>
|
||||
addClaimedSubdomain(new Map(), 'portal', {
|
||||
mainPipeline: true,
|
||||
...gate,
|
||||
} as unknown as SubdomainClaim),
|
||||
).toThrow(/main pipeline with a gate/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a claim without authorize', () => {
|
||||
for (const bad of [undefined, {}, { authorize: true }]) {
|
||||
expect(() =>
|
||||
@@ -806,6 +891,18 @@ describe('createNativeAppStatic', () => {
|
||||
const withRoot = () =>
|
||||
createNativeAppStatic({ native_apps_root: root } as unknown as IConfig);
|
||||
|
||||
it('leaves a native app name alone on a host outside our domains', async () => {
|
||||
const req = staticReq({
|
||||
subdomains: ['com', 'example', 'editor'],
|
||||
path: '/index.html',
|
||||
});
|
||||
(req as Request & { is_custom_domain?: boolean }).is_custom_domain =
|
||||
true;
|
||||
const { out, next } = await runStatic(withRoot(), req);
|
||||
expect(out.sent).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is a no-op when native_apps_root is unset', async () => {
|
||||
const { out, next } = await runStatic(
|
||||
createNativeAppStatic(config),
|
||||
|
||||
@@ -41,8 +41,9 @@ const NATIVE_APP_SUBDOMAINS = [
|
||||
const NATIVE_APPS_WITH_DIST = new Set(['docs', 'developer']);
|
||||
|
||||
/**
|
||||
* Subdomains that v2 serves itself. Anything NOT in this set that lives on the
|
||||
* root domain is treated as a user-defined site and rejected with a 404.
|
||||
* Subdomains that v2 serves itself. A root-domain subdomain neither in this set
|
||||
* nor claimed by an extension is treated as a user-defined site and rejected
|
||||
* with a 404.
|
||||
*
|
||||
* Kept as a plain Set so `has()` is O(1); order doesn't matter.
|
||||
*/
|
||||
@@ -55,8 +56,6 @@ const RESERVED_SUBDOMAINS = new Set<string>([
|
||||
...NATIVE_APP_SUBDOMAINS,
|
||||
// App-icon serving subdomain.
|
||||
'puter-app-icons',
|
||||
// Extension-owned subdomains.
|
||||
'onlyoffice',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -69,7 +68,8 @@ export type ClaimAuthorize = (
|
||||
res: Response,
|
||||
) => boolean | Promise<boolean>;
|
||||
|
||||
export interface SubdomainClaim {
|
||||
/** A claim answered on its own router, only past `authorize`. */
|
||||
export interface GatedSubdomainClaim {
|
||||
authorize: ClaimAuthorize;
|
||||
/**
|
||||
* Vets each route declared for the claim as the server builds it; a throw
|
||||
@@ -78,16 +78,39 @@ export interface SubdomainClaim {
|
||||
checkRoute?: (options: RouteOptions, label: string) => void;
|
||||
}
|
||||
|
||||
/** A claim as dispatch sees it: its gate, plus the routes served behind it. */
|
||||
export interface ResolvedClaim {
|
||||
authorize: ClaimAuthorize;
|
||||
handler?: RequestHandler;
|
||||
/** Served like core's own subdomains: CORS, auth and every route. */
|
||||
export interface MainPipelineSubdomainClaim {
|
||||
mainPipeline: true;
|
||||
}
|
||||
|
||||
export type SubdomainClaim = GatedSubdomainClaim | MainPipelineSubdomainClaim;
|
||||
|
||||
/** A claim as dispatch sees it: a gate and the routes behind it, or neither. */
|
||||
export type ResolvedClaim =
|
||||
| { authorize: ClaimAuthorize; handler?: RequestHandler }
|
||||
| MainPipelineSubdomainClaim;
|
||||
|
||||
export const isMainPipelineClaim = (
|
||||
claim: SubdomainClaim | ResolvedClaim | undefined,
|
||||
): claim is MainPipelineSubdomainClaim =>
|
||||
(claim as { mainPipeline?: unknown } | undefined)?.mainPipeline === true;
|
||||
|
||||
export type ClaimLookup = (name: string) => ResolvedClaim | undefined;
|
||||
|
||||
const SUBDOMAIN_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
/** The label `x` when `host` is exactly `x.<domain>`, ignoring case and port. */
|
||||
export const subdomainLabelOf = (
|
||||
host: string,
|
||||
domain: string,
|
||||
): string | undefined => {
|
||||
const hostName = host.toLowerCase().split(':')[0];
|
||||
const base = domain.toLowerCase().split(':')[0];
|
||||
if (!base || !hostName.endsWith(`.${base}`)) return undefined;
|
||||
const label = hostName.slice(0, -(base.length + 1));
|
||||
return label && !label.includes('.') ? label : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Record a claim on `<name>.<domain>`. Throws on a malformed or uppercase
|
||||
* label, a subdomain core already handles, or a name that is already claimed.
|
||||
@@ -106,6 +129,15 @@ export const addClaimedSubdomain = (
|
||||
if (claims.has(name)) {
|
||||
throw new Error(`Subdomain '${name}' is already claimed`);
|
||||
}
|
||||
if (isMainPipelineClaim(claim)) {
|
||||
if ('authorize' in claim || 'checkRoute' in claim) {
|
||||
throw new Error(
|
||||
`Subdomain '${name}' claimed for the main pipeline with a gate`,
|
||||
);
|
||||
}
|
||||
claims.set(name, { mainPipeline: true });
|
||||
return;
|
||||
}
|
||||
if (typeof claim?.authorize !== 'function') {
|
||||
throw new Error(`Subdomain '${name}' claimed without authorize`);
|
||||
}
|
||||
@@ -131,7 +163,7 @@ const subdomainNotFound = () =>
|
||||
* unclaimed-subdomain 404, and errors reach the error handler.
|
||||
*/
|
||||
const dispatchClaimed = async (
|
||||
claim: ResolvedClaim,
|
||||
claim: Exclude<ResolvedClaim, MainPipelineSubdomainClaim>,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
@@ -175,6 +207,8 @@ const dispatchClaimed = async (
|
||||
export const createWwwRedirect = (config: IConfig): RequestHandler => {
|
||||
const domain = (config.domain ?? '').toLowerCase();
|
||||
return (req, res, next) => {
|
||||
// `www.` on a host outside our domains belongs to that host's site.
|
||||
if (req.is_custom_domain) return next();
|
||||
const active = req.subdomains?.[req.subdomains.length - 1] ?? '';
|
||||
if (active !== 'www') return next();
|
||||
if (!domain) return next();
|
||||
@@ -190,7 +224,8 @@ export const createWwwRedirect = (config: IConfig): RequestHandler => {
|
||||
* Passes through when:
|
||||
*
|
||||
* - No active subdomain (root)
|
||||
* - Active subdomain is reserved (api, js, native apps, …)
|
||||
* - Active subdomain is reserved (api, js, native apps, …) or claimed for the
|
||||
* main pipeline
|
||||
* - Host is on one of the hosting domains (they may nest under `config.domain`)
|
||||
* - Host is a local worker host and the local worker server is on
|
||||
* - Host isn't under `config.domain` (custom domains, other hosts)
|
||||
@@ -223,6 +258,8 @@ export const createUserSubdomainNotFound = (
|
||||
req.subdomains?.[req.subdomains.length - 1] ?? ''
|
||||
).toLowerCase();
|
||||
if (active === '' || RESERVED_SUBDOMAINS.has(active)) return next();
|
||||
const claim = lookupClaim(active);
|
||||
if (isMainPipelineClaim(claim)) return next();
|
||||
|
||||
const hostName = (req.headers.host ?? '').toLowerCase().split(':')[0];
|
||||
if (
|
||||
@@ -239,8 +276,9 @@ export const createUserSubdomainNotFound = (
|
||||
// responder, body parsing, auth and every route, `subdomain: '*'` ones
|
||||
// included. One label deep only: `<name>.x.<domain>` is not the claim.
|
||||
const claimed =
|
||||
req.subdomains?.length === 1 && hostName === `${active}.${domain}`
|
||||
? lookupClaim(active)
|
||||
req.subdomains?.length === 1 &&
|
||||
subdomainLabelOf(hostName, domain) === active
|
||||
? claim
|
||||
: undefined;
|
||||
if (claimed) {
|
||||
void dispatchClaimed(claimed, req, res, next);
|
||||
@@ -269,6 +307,7 @@ export const createNativeAppStatic = (config: IConfig): RequestHandler => {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
return async (req, res, next) => {
|
||||
if (req.is_custom_domain) return next();
|
||||
const active = (
|
||||
req.subdomains?.[req.subdomains.length - 1] ?? ''
|
||||
).toLowerCase();
|
||||
|
||||
@@ -950,7 +950,8 @@ describe('resolvePrivateIdentity', () => {
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts an app-issued access token minted for the target app', async () => {
|
||||
it('ignores an app-issued access token even for the target app', async () => {
|
||||
// A scoped token (e.g. a read URL) is not the account, whoever issued it.
|
||||
const user = await makeUser();
|
||||
const targetUid = `app-target-${uuidv4()}`;
|
||||
const issuer = makeActor({
|
||||
@@ -970,11 +971,11 @@ describe('resolvePrivateIdentity', () => {
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: targetUid,
|
||||
});
|
||||
expect(out.source).toBe('session-cookie');
|
||||
expect(out.userUid).toBe(user.uuid);
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still accepts a user-issued access token, which is portable', async () => {
|
||||
it('ignores a user-issued scoped access token', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
@@ -983,7 +984,46 @@ describe('resolvePrivateIdentity', () => {
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
session: { uid: 'sess-1' },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: `app-target-${uuidv4()}`,
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a scoped access token when the host has no app', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
});
|
||||
|
||||
it('accepts a full-access token, which holds the account', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: {
|
||||
uid: `tok-${uuidv4()}`,
|
||||
issuer,
|
||||
fullAccess: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '../../../services/auth/AuthService';
|
||||
import type { AppStore } from '../../../stores/app/AppStore.js';
|
||||
import type { IConfig } from '../../../types';
|
||||
import type { Actor } from '../../actor';
|
||||
import { isAccessTokenActor, isAccountContext, type Actor } from '../../actor';
|
||||
|
||||
/**
|
||||
* Support helpers for the private-app access gate — ported from v1's
|
||||
@@ -196,10 +196,12 @@ export async function resolveOwnedAppForHostedSite(opts: {
|
||||
appStore: Pick<AppStore, 'listOwnedByIndexUrlCandidates'>;
|
||||
config: PrivateHostingConfig;
|
||||
requirePrivate?: boolean;
|
||||
/** Match against this hosting-domain host instead of the request's. */
|
||||
host?: string;
|
||||
}): Promise<AppLike | null> {
|
||||
if (!opts.site?.user_id) return null;
|
||||
|
||||
const host = normalizeHost(opts.req.hostname);
|
||||
const host = normalizeHost(opts.host ?? opts.req.hostname);
|
||||
if (!host) return null;
|
||||
|
||||
const hostedSubdomain = subdomainFromHost(host, [
|
||||
@@ -215,7 +217,7 @@ export async function resolveOwnedAppForHostedSite(opts: {
|
||||
const hostCandidates = new Set<string>();
|
||||
hostCandidates.add(host);
|
||||
const headerHost =
|
||||
typeof opts.req.headers?.host === 'string'
|
||||
!opts.host && typeof opts.req.headers?.host === 'string'
|
||||
? opts.req.headers.host.trim().toLowerCase()
|
||||
: '';
|
||||
if (headerHost) hostCandidates.add(headerHost);
|
||||
@@ -458,11 +460,15 @@ export async function resolvePrivateIdentity(opts: {
|
||||
* "No app anywhere in the chain" is the portable case, so read `effectiveApp` —
|
||||
* an app-issued access token has no `app` of its own and would otherwise pass
|
||||
* as a plain session on any private host.
|
||||
*
|
||||
* A scoped access token (a read URL, whoever issued it) is never the account
|
||||
* here: the hosted cookie minted from it would outlive the token's revocation.
|
||||
*/
|
||||
function actorMatchesExpectedApp(
|
||||
actor: Actor,
|
||||
expectedAppUid: string | undefined,
|
||||
): boolean {
|
||||
if (isAccessTokenActor(actor) && !isAccountContext(actor)) return false;
|
||||
if (!expectedAppUid) return true;
|
||||
const app = actor.effectiveApp;
|
||||
if (!app) return true;
|
||||
|
||||
@@ -1030,6 +1030,133 @@ describe('createPuterSiteMiddleware — site.access.check', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPuterSiteMiddleware — hosts outside the configured domains', () => {
|
||||
const claims = new Map<string, string>();
|
||||
const resolveHost = (
|
||||
_key: string,
|
||||
data: { host: string; result: { subdomain: string | null } },
|
||||
) => {
|
||||
const sub = claims.get(data.host);
|
||||
if (sub) data.result.subdomain = sub;
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
server.clients.event.on('site.resolveHost', resolveHost as never);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.clients.event.off('site.resolveHost', resolveHost as never);
|
||||
});
|
||||
|
||||
const customReq = (hostname: string, path = '/index.html') => {
|
||||
const req = makeReq({ hostname, path });
|
||||
(req as Request & { is_custom_domain?: boolean }).is_custom_domain =
|
||||
true;
|
||||
return req;
|
||||
};
|
||||
|
||||
const makeSite = async (opts: { suspended?: boolean } = {}) => {
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
|
||||
const sub = `cust-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
rootDirId: homeEntry!.id,
|
||||
});
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/index.html`,
|
||||
Buffer.from('<html>custom</html>'),
|
||||
'text/html',
|
||||
);
|
||||
if (opts.suspended) {
|
||||
await server.stores.user.update(owner.id, { suspended: 1 });
|
||||
}
|
||||
const host = `${sub}.example.test`;
|
||||
claims.set(host, sub);
|
||||
return { owner, sub, host };
|
||||
};
|
||||
|
||||
it('serves the claimed site on its host', async () => {
|
||||
const { host } = await makeSite();
|
||||
const { out, next } = await runMiddleware(
|
||||
buildMiddleware(),
|
||||
customReq(host),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(String(out.body)).toBe('<html>custom</html>');
|
||||
});
|
||||
|
||||
it('404s an unclaimed host instead of falling through to the API/GUI', async () => {
|
||||
const { out, next } = await runMiddleware(
|
||||
buildMiddleware(),
|
||||
customReq('nobody-claims-this.example.test'),
|
||||
);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(out.body).toBe('Site not found');
|
||||
});
|
||||
|
||||
it('404s when a listener throws', async () => {
|
||||
const host = 'throws.example.test';
|
||||
const boom = (_key: string, data: { host: string }) => {
|
||||
if (data.host === host) throw new Error('boom');
|
||||
};
|
||||
server.clients.event.on('site.resolveHost', boom as never);
|
||||
try {
|
||||
const { out, next } = await runMiddleware(
|
||||
buildMiddleware(),
|
||||
customReq(host),
|
||||
);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(out.statusCode).toBe(404);
|
||||
} finally {
|
||||
server.clients.event.off('site.resolveHost', boom as never);
|
||||
}
|
||||
});
|
||||
|
||||
it('404s a claimed site whose owner is suspended', async () => {
|
||||
const { host } = await makeSite({ suspended: true });
|
||||
const { out } = await runMiddleware(buildMiddleware(), customReq(host));
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(out.body).toBe('Subdomain not found');
|
||||
});
|
||||
|
||||
it('404s a private app rather than serving it ungated', async () => {
|
||||
const { owner, sub, host } = await makeSite();
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`, `is_private`) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
`app-${uuidv4()}`,
|
||||
sub,
|
||||
sub,
|
||||
`http://${sub}.site.puter.localhost/`,
|
||||
owner.id,
|
||||
1,
|
||||
],
|
||||
);
|
||||
const { out } = await runMiddleware(buildMiddleware(), customReq(host));
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(out.body).toBe('Site not found');
|
||||
expect(out.redirected).toBeUndefined();
|
||||
expect(out.cookies).toEqual({});
|
||||
});
|
||||
|
||||
it('passes the host through untouched when custom domains are not flagged', async () => {
|
||||
const { host } = await makeSite();
|
||||
const { out, next } = await runMiddleware(
|
||||
buildMiddleware(),
|
||||
makeReq({ hostname: host }),
|
||||
);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(out.statusCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPuterSiteMiddleware — .puter_site_config', () => {
|
||||
const setupSiteWithConfig = async (config: unknown) => {
|
||||
const owner = await makeUserWithHome();
|
||||
@@ -1254,12 +1381,7 @@ describe('createPuterSiteMiddleware — .puter_site_config', () => {
|
||||
errors: { '404': { file: '/index.html', status: 200 } },
|
||||
});
|
||||
const body = Buffer.from('real-page');
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/page.html`,
|
||||
body,
|
||||
'text/html',
|
||||
);
|
||||
await writeFile(owner.id, `${homePath}/page.html`, body, 'text/html');
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
@@ -1373,9 +1495,9 @@ describe('createPuterSiteMiddleware — .puter_site_config', () => {
|
||||
|
||||
// SPA fallback still fires → cache served the deleted config.
|
||||
expect(second.out.statusCode).toBe(200);
|
||||
expect(
|
||||
(second.out.body as Buffer | undefined)?.equals(body),
|
||||
).toBe(true);
|
||||
expect((second.out.body as Buffer | undefined)?.equals(body)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores `errors` entries with status codes outside 4xx/5xx', async () => {
|
||||
|
||||
@@ -61,12 +61,12 @@ import {
|
||||
* - Private-app gate via `app.privateAccess.check` — marketplace extension
|
||||
* decides; default is denied + redirect to `app-center`
|
||||
* - Range / ETag / Last-Modified passthrough via `fsEntry.readContent`
|
||||
* - Hosts outside the configured domains (`custom_domains_enabled`) map to a site
|
||||
* via `site.resolveHost`; unclaimed ones 404. Public sites only.
|
||||
*
|
||||
* Deferred (not yet implemented):
|
||||
*
|
||||
* - `.at` username-based sites (UUIDv5-keyed `/user/Public`).
|
||||
* - Custom domains (subdomains table `domain` column) — requires host validation
|
||||
* to allow arbitrary hostnames first.
|
||||
*
|
||||
* Site config:
|
||||
*
|
||||
@@ -228,29 +228,60 @@ export const createPuterSiteMiddleware = (
|
||||
if (!host) return next();
|
||||
|
||||
const matched = matchHostingDomain(host);
|
||||
if (!matched) return next();
|
||||
// A host outside every configured domain. Listeners may map it to a
|
||||
// site; an unclaimed one is answered here rather than falling through
|
||||
// to the API/GUI routers.
|
||||
const isCustomHost = !matched && Boolean(req.is_custom_domain);
|
||||
if (!matched && !isCustomHost) return next();
|
||||
|
||||
// Bare hosting domain (e.g. `puter.site`) → redirect to the main site.
|
||||
if (host === matched) {
|
||||
if (domain) {
|
||||
res.redirect(302, `${req.protocol}://${domain}`);
|
||||
let subdomain: string;
|
||||
if (isCustomHost) {
|
||||
const resolveEvent = {
|
||||
host,
|
||||
result: { subdomain: null as string | null },
|
||||
};
|
||||
try {
|
||||
await layers.clients.event.emitAndWait(
|
||||
'site.resolveHost',
|
||||
resolveEvent,
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
resolveEvent.result.subdomain = null;
|
||||
}
|
||||
const claimed = resolveEvent.result.subdomain;
|
||||
if (typeof claimed !== 'string' || !claimed) {
|
||||
res.status(404).type('text/plain').send('Site not found');
|
||||
return;
|
||||
}
|
||||
res.status(404).type('text/plain').send('Subdomain not found');
|
||||
return;
|
||||
}
|
||||
subdomain = claimed;
|
||||
} else {
|
||||
const hostingDomain = matched as string;
|
||||
// Bare hosting domain (e.g. `puter.site`) → redirect to the main site.
|
||||
if (host === hostingDomain) {
|
||||
if (domain) {
|
||||
res.redirect(302, `${req.protocol}://${domain}`);
|
||||
return;
|
||||
}
|
||||
res.status(404).type('text/plain').send('Subdomain not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// `host` is `<prefix>.<matched>`; subdomain is the left-most label.
|
||||
const prefix = host.slice(0, host.length - matched.length - 1);
|
||||
const subdomain = prefix.split('.')[0] || '';
|
||||
// `host` is `<prefix>.<matched>`; subdomain is the left-most label.
|
||||
const prefix = host.slice(
|
||||
0,
|
||||
host.length - hostingDomain.length - 1,
|
||||
);
|
||||
subdomain = prefix.split('.')[0] || '';
|
||||
|
||||
if (!subdomain || subdomain === 'www') {
|
||||
if (domain) {
|
||||
res.redirect(302, `${req.protocol}://${domain}`);
|
||||
if (!subdomain || subdomain === 'www') {
|
||||
if (domain) {
|
||||
res.redirect(302, `${req.protocol}://${domain}`);
|
||||
return;
|
||||
}
|
||||
res.status(404).type('text/plain').send('Subdomain not found');
|
||||
return;
|
||||
}
|
||||
res.status(404).type('text/plain').send('Subdomain not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const site = (await layers.stores.subdomain.getBySubdomain(
|
||||
@@ -286,6 +317,11 @@ export const createPuterSiteMiddleware = (
|
||||
site: { user_id: site.user_id },
|
||||
appStore: layers.stores.app,
|
||||
config: hostingCfg,
|
||||
// App index URLs name the hosting-domain address, not the
|
||||
// custom host the visitor used.
|
||||
host: isCustomHost
|
||||
? `${subdomain}.${hostingDomains[0]}`
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
const rootLookup =
|
||||
@@ -300,7 +336,14 @@ export const createPuterSiteMiddleware = (
|
||||
const isPrivateApp = Boolean(associatedApp?.is_private);
|
||||
const privateApp = isPrivateApp ? associatedApp : null;
|
||||
|
||||
if (isPrivateApp) {
|
||||
if (isCustomHost) {
|
||||
// The private-app gate and its cookies are bound to the private
|
||||
// hosting domain, so these hosts serve public sites only.
|
||||
if (isPrivateApp) {
|
||||
res.status(404).type('text/plain').send('Site not found');
|
||||
return;
|
||||
}
|
||||
} else if (isPrivateApp) {
|
||||
// Private apps must run on the private hosting domain. If a
|
||||
// visitor arrives via the public domain (puter.site), redirect
|
||||
// them to the equivalent private-host URL so the cookie scope
|
||||
@@ -448,7 +491,7 @@ export const createPuterSiteMiddleware = (
|
||||
res.redirect(302, cleanPath);
|
||||
return;
|
||||
}
|
||||
} else if (privateHostingDomains.has(matched)) {
|
||||
} else if (privateHostingDomains.has(matched as string)) {
|
||||
// Non-private content landed on the private hosting domain —
|
||||
// mirror of the private redirect above. Covers two cases:
|
||||
// - a paid app that just flipped to free (is_private 1→0)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What core asks of Teams. Nothing here is an implementation: the pair is
|
||||
* registered by whatever provides the feature, and a deployment that does not
|
||||
* run it registers neither, so every caller treats both as absent.
|
||||
*/
|
||||
|
||||
/** A team, as much of one as core reads back. */
|
||||
export interface TeamRow {
|
||||
id: number;
|
||||
uid: string;
|
||||
owner_user_id: number;
|
||||
kind: string;
|
||||
name: string | null;
|
||||
handle: string | null;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
/** Whether an app acting for a member may read the member list. */
|
||||
directory_enabled: number;
|
||||
/** Whether the accounts this team provisioned must hold 2FA. */
|
||||
require_2fa: number;
|
||||
}
|
||||
|
||||
/** The seat a user holds, for the gates that ask whether they hold one. */
|
||||
export interface OrgSeatRow {
|
||||
id: number;
|
||||
user_id: number;
|
||||
uuid: string;
|
||||
username: string;
|
||||
team_uid: string;
|
||||
team_name: string | null;
|
||||
owner_user_id: number;
|
||||
/** Carried here so the per-request 2FA gate costs no extra read. */
|
||||
require_2fa: number;
|
||||
}
|
||||
|
||||
/** Most members one notification fans out to. */
|
||||
export const NOTIFY_FANOUT_CAP = 500;
|
||||
|
||||
/** The reads core makes of teams it did not create. */
|
||||
export interface TeamStoreContract {
|
||||
getOrgSeat(userId: number): Promise<OrgSeatRow | null>;
|
||||
isMember(teamUid: string, userId: number): Promise<boolean>;
|
||||
getByUid(uid: string): Promise<TeamRow | null>;
|
||||
getByHandle(handle: string): Promise<TeamRow | null>;
|
||||
getByIdIncludingDeleted(id: number): Promise<TeamRow | null>;
|
||||
getByIdsIncludingDeleted(ids: number[]): Promise<Map<number, TeamRow>>;
|
||||
listGroupIdsForUser(userId: number): Promise<number[]>;
|
||||
listMemberIdsByGroupId(groupId: number): Promise<number[]>;
|
||||
memberIdsAmong(teamUid: string, userIds: number[]): Promise<number[]>;
|
||||
countOwned(ownerUserId: number): Promise<number>;
|
||||
countOwnedTeamsWithAccounts(ownerUserId: number): Promise<number>;
|
||||
}
|
||||
|
||||
/** The calls core makes when an account it owns changes. */
|
||||
export interface TeamServiceContract {
|
||||
teamsAvailableTo(userId: number, email: string | null): Promise<boolean>;
|
||||
captureSeatForBilling(userId: number): Promise<unknown | null>;
|
||||
emitSeatDeleted(seat: unknown): void;
|
||||
forgetSeat(seat: unknown): Promise<void>;
|
||||
releaseUnconfirmedSeatEmail(userId: number): Promise<boolean>;
|
||||
recordPasswordSelfChange(userId: number): Promise<void>;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ describe('chat cancellation over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
let restoreFetch: () => void;
|
||||
let actor: Actor;
|
||||
let onRequest: (res: ServerResponse) => void;
|
||||
let onRequest: (res: ServerResponse, body: string) => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
upstream = createServer((req, res) => {
|
||||
@@ -44,8 +44,12 @@ describe('chat cancellation over HTTP', () => {
|
||||
res.end(JSON.stringify({ models: [{ name: 'cancel-test' }] }));
|
||||
return;
|
||||
}
|
||||
req.resume();
|
||||
req.once('end', () => onRequest(res));
|
||||
let body = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk: string) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.once('end', () => onRequest(res, body));
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
upstream.listen(0, '127.0.0.1', resolve),
|
||||
@@ -143,24 +147,137 @@ describe('chat cancellation over HTTP', () => {
|
||||
);
|
||||
};
|
||||
|
||||
const usage = { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 };
|
||||
const responsesUsage = {
|
||||
input_tokens: 3,
|
||||
output_tokens: 5,
|
||||
total_tokens: 8,
|
||||
input_tokens_details: { cached_tokens: 0 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
};
|
||||
const response = {
|
||||
id: 'resp_1',
|
||||
object: 'response',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
id: 'msg_1',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{ type: 'output_text', text: 'done', annotations: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
usage: responsesUsage,
|
||||
};
|
||||
const sse = (events: unknown[]) =>
|
||||
events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join('');
|
||||
|
||||
/**
|
||||
* A complete upstream answer, with usage, in the shape the request asked
|
||||
* for.
|
||||
*/
|
||||
const finishAnswer = (res: ServerResponse, body: string) => {
|
||||
const stream = (JSON.parse(body) as { stream?: boolean }).stream;
|
||||
const responses = res.req.url === '/v1/responses';
|
||||
if (!stream) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify(
|
||||
responses
|
||||
? response
|
||||
: {
|
||||
id: 'chatcmpl_1',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model: 'cancel-test',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'done',
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage,
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!res.headersSent)
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.end(
|
||||
responses
|
||||
? sse([
|
||||
{ type: 'response.output_text.delta', delta: 'done' },
|
||||
{ type: 'response.completed', response },
|
||||
])
|
||||
: `${sse([
|
||||
{
|
||||
id: 'chatcmpl_1',
|
||||
object: 'chat.completion.chunk',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content: 'done' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'chatcmpl_1',
|
||||
object: 'chat.completion.chunk',
|
||||
choices: [],
|
||||
usage,
|
||||
},
|
||||
])}data: [DONE]\n\n`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a usage entry the upstream reported, not an estimate, was
|
||||
* recorded.
|
||||
*/
|
||||
const meteredReportedUsage = (spy: ReturnType<typeof vi.spyOn>) =>
|
||||
spy.mock.calls
|
||||
.flatMap((call) => (call[1] as { usageType: string }[]) ?? [])
|
||||
.some(
|
||||
(u) =>
|
||||
!u.usageType.includes('estimated_') &&
|
||||
/completion|output_tokens/.test(u.usageType),
|
||||
);
|
||||
|
||||
it.each(
|
||||
routes.flatMap((route) =>
|
||||
[false, true].map((stream) => ({ route, stream })),
|
||||
),
|
||||
)(
|
||||
'cancels upstream while waiting for headers: $route (stream=$stream)',
|
||||
'bills an upstream the caller left while waiting for headers: $route (stream=$stream)',
|
||||
async ({ route, stream }) => {
|
||||
let upstreamClosed = false;
|
||||
let upstreamRes!: ServerResponse;
|
||||
let upstreamBody = '';
|
||||
let upstreamClosedEarly = false;
|
||||
let received!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
received = resolve;
|
||||
});
|
||||
onRequest = (res) => {
|
||||
onRequest = (res, body) => {
|
||||
upstreamRes = res;
|
||||
upstreamBody = body;
|
||||
res.once('close', () => {
|
||||
upstreamClosed = true;
|
||||
if (!res.writableFinished) upstreamClosedEarly = true;
|
||||
});
|
||||
received();
|
||||
};
|
||||
const metered = vi.spyOn(
|
||||
env.server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const request = call(route, abort.signal, stream).catch(
|
||||
(error: unknown) => error,
|
||||
@@ -176,36 +293,50 @@ describe('chat cancellation over HTTP', () => {
|
||||
);
|
||||
}),
|
||||
]);
|
||||
abort.abort();
|
||||
expect(await request).toMatchObject({ name: 'AbortError' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(upstreamClosedEarly).toBe(false);
|
||||
expect(
|
||||
await env.server.services.metering.getOutstandingHolds(
|
||||
actor,
|
||||
),
|
||||
).toBeGreaterThan(0);
|
||||
abort.abort();
|
||||
expect(await request).toMatchObject({ name: 'AbortError' });
|
||||
await expect.poll(() => upstreamClosed).toBe(true);
|
||||
|
||||
finishAnswer(upstreamRes, upstreamBody);
|
||||
await expect
|
||||
.poll(() =>
|
||||
env.server.services.metering.getOutstandingHolds(actor),
|
||||
)
|
||||
.toBe(0);
|
||||
expect(upstreamClosedEarly).toBe(false);
|
||||
expect(meteredReportedUsage(metered)).toBe(true);
|
||||
} finally {
|
||||
abort.abort();
|
||||
metered.mockRestore();
|
||||
upstream.closeAllConnections();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(routes)(
|
||||
'cancels an idle upstream after partial output: %s',
|
||||
'bills an upstream the caller left after partial output: %s',
|
||||
async (route) => {
|
||||
let upstreamClosed = false;
|
||||
onRequest = (res) => {
|
||||
let upstreamRes!: ServerResponse;
|
||||
let upstreamBody = '';
|
||||
let upstreamClosedEarly = false;
|
||||
onRequest = (res, body) => {
|
||||
upstreamRes = res;
|
||||
upstreamBody = body;
|
||||
res.once('close', () => {
|
||||
upstreamClosed = true;
|
||||
if (!res.writableFinished) upstreamClosedEarly = true;
|
||||
});
|
||||
writeText(res);
|
||||
};
|
||||
const metered = vi.spyOn(
|
||||
env.server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
);
|
||||
const abort = new AbortController();
|
||||
try {
|
||||
const response = await call(route, abort.signal);
|
||||
@@ -219,14 +350,20 @@ describe('chat cancellation over HTTP', () => {
|
||||
text += new TextDecoder().decode(chunk.value);
|
||||
}
|
||||
abort.abort();
|
||||
await expect.poll(() => upstreamClosed).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(upstreamClosedEarly).toBe(false);
|
||||
|
||||
finishAnswer(upstreamRes, upstreamBody);
|
||||
await expect
|
||||
.poll(() =>
|
||||
env.server.services.metering.getOutstandingHolds(actor),
|
||||
)
|
||||
.toBe(0);
|
||||
expect(upstreamClosedEarly).toBe(false);
|
||||
expect(meteredReportedUsage(metered)).toBe(true);
|
||||
} finally {
|
||||
abort.abort();
|
||||
metered.mockRestore();
|
||||
upstream.closeAllConnections();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -633,6 +633,40 @@ describe('ChatCompletionDriver exhausted-chain classification', () => {
|
||||
expect(err.message).toBe('unsupported parameter: top_k');
|
||||
});
|
||||
|
||||
it('maps a gateway 4xx with no upstream error body to 400 upstream_provider_unavailable', async () => {
|
||||
const err = await errorFor({
|
||||
status: 400,
|
||||
message: '400 bad response status code 400',
|
||||
error: {
|
||||
type: 'upstream_error',
|
||||
message: 'bad response status code 400',
|
||||
code: 400,
|
||||
},
|
||||
});
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_provider_unavailable',
|
||||
});
|
||||
expect(err.message).toBe('AI provider unavailable');
|
||||
});
|
||||
|
||||
it('keeps a gateway 4xx that carries the vendor message as the caller’s upstream_bad_request', async () => {
|
||||
const err = await errorFor({
|
||||
status: 400,
|
||||
message:
|
||||
'400 bad response status code 400, message: max_tokens too large, body: {}',
|
||||
error: {
|
||||
type: 'upstream_error',
|
||||
message:
|
||||
'bad response status code 400, message: max_tokens too large, body: {}',
|
||||
},
|
||||
});
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_bad_request',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a completion with no choices to a 400, not an internal 500', async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'complete').mockImplementation(
|
||||
() =>
|
||||
@@ -809,6 +843,26 @@ describe('ChatCompletionDriver cross-provider fallback', () => {
|
||||
expect(line).toContain('first 422');
|
||||
});
|
||||
|
||||
it('falls back past a gateway 4xx with no upstream error body and marks the route unhealthy', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.spyOn(AzureChatProvider.prototype, 'complete').mockRejectedValue(
|
||||
Object.assign(new Error('400 bad response status code 400'), {
|
||||
status: 400,
|
||||
error: { type: 'upstream_error' },
|
||||
}),
|
||||
);
|
||||
vi.spyOn(OpenAiChatProvider.prototype, 'complete').mockResolvedValue({
|
||||
message: { role: 'assistant', content: 'from the fallback' },
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
} as never);
|
||||
|
||||
const result = (await completeShared()) as {
|
||||
message: { content: string };
|
||||
};
|
||||
expect(result.message.content).toBe('from the fallback');
|
||||
expect(isRouteUnhealthy('azure-openai', SHARED_MODEL)).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces the primary route-level 4xx after a non-strict fallback also fails', async () => {
|
||||
vi.spyOn(AzureChatProvider.prototype, 'complete').mockRejectedValue(
|
||||
Object.assign(new Error('unsupported parameter: frobnicate'), {
|
||||
|
||||
@@ -47,7 +47,8 @@ import { ChatCompletionDriver } from './ChatCompletionDriver.js';
|
||||
import { clearUnhealthyRoutes, isRouteUnhealthy } from './utils/providerHealth.js';
|
||||
import { FakeChatProvider } from './providers/FakeChatProvider.js';
|
||||
import type { IChatCompleteResult } from './types.js';
|
||||
import { type AIChatStream, ChatStreamAbortedError } from './utils/Streaming.js';
|
||||
import { SDK_TIMEOUT_MS } from './utils/sdkClient.js';
|
||||
import type { AIChatStream } from './utils/Streaming.js';
|
||||
|
||||
let server: PuterServer;
|
||||
|
||||
@@ -905,7 +906,7 @@ describe('ChatCompletionDriver when the caller hangs up', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('stops the generation and gives the hold back', async () => {
|
||||
it('keeps generating after the hang-up and bills the reported usage', async () => {
|
||||
const actor = freeUser() as never;
|
||||
const metering = server.services.metering;
|
||||
const abort = new AbortController();
|
||||
@@ -914,6 +915,7 @@ describe('ChatCompletionDriver when the caller hangs up', () => {
|
||||
resume = r;
|
||||
});
|
||||
let providerError: unknown;
|
||||
let finished = false;
|
||||
|
||||
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce(
|
||||
streamOf(async ({ chatStream }) => {
|
||||
@@ -921,8 +923,13 @@ describe('ChatCompletionDriver when the caller hangs up', () => {
|
||||
block.addText('first');
|
||||
await paused;
|
||||
try {
|
||||
// An upstream still generating: keeps writing until told.
|
||||
for (let i = 0; i < 1000; i++) block.addText('more');
|
||||
// An upstream still generating after the caller left.
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
await chatStream.waitForDrain();
|
||||
block.addText('more');
|
||||
}
|
||||
chatStream.end({ input_tokens: 3, output_tokens: 1001 });
|
||||
finished = true;
|
||||
} catch (e) {
|
||||
providerError = e;
|
||||
throw e;
|
||||
@@ -930,6 +937,7 @@ describe('ChatCompletionDriver when the caller hangs up', () => {
|
||||
}) as never,
|
||||
);
|
||||
const metered = vi.spyOn(metering, 'batchIncrementUsages');
|
||||
const events = vi.spyOn(server.clients.event, 'emit');
|
||||
|
||||
const { stream } = (await withTestActor(() => {
|
||||
Context.set('abortSignal', abort.signal);
|
||||
@@ -950,15 +958,120 @@ describe('ChatCompletionDriver when the caller hangs up', () => {
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
expect(providerError).toBeInstanceOf(ChatStreamAbortedError);
|
||||
// Nobody is listening, so no error line is written for the abort.
|
||||
expect(body).not.toContain('"type":"error"');
|
||||
// The hold goes back once the usage write lands.
|
||||
expect(providerError).toBeUndefined();
|
||||
expect(finished).toBe(true);
|
||||
// Nothing reaches a caller who has gone, including the usage line.
|
||||
expect(body).not.toContain('more');
|
||||
expect(body).not.toContain('"type":"usage"');
|
||||
await vi.waitFor(async () =>
|
||||
expect(await metering.getOutstandingHolds(actor)).toBe(0),
|
||||
);
|
||||
expect(
|
||||
meteredUsages(metered).some((u) =>
|
||||
u.usageType.includes('estimated_'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
events.mock.calls.some(
|
||||
([key, data]) =>
|
||||
key === 'ai.prompt.cost-calculated' &&
|
||||
(data as { usage?: { output_tokens?: number } }).usage
|
||||
?.output_tokens === 1001,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('bills a stream whose caller left before the first token', async () => {
|
||||
const actor = freeUser() as never;
|
||||
const abort = new AbortController();
|
||||
vi.spyOn(FakeChatProvider.prototype, 'complete').mockImplementationOnce(
|
||||
async () => {
|
||||
// The caller hangs up while the upstream is still answering.
|
||||
abort.abort();
|
||||
return streamOf(async ({ chatStream }) => {
|
||||
chatStream
|
||||
.message()
|
||||
.contentBlock({ type: 'text' })
|
||||
.addText('answer');
|
||||
chatStream.end({ input_tokens: 3, output_tokens: 2 });
|
||||
});
|
||||
},
|
||||
);
|
||||
const events = vi.spyOn(server.clients.event, 'emit');
|
||||
const { stream } = (await withTestActor(() => {
|
||||
Context.set('abortSignal', abort.signal);
|
||||
return driver.complete({
|
||||
model: 'priced',
|
||||
messages: [{ role: 'user', content: 'go' }],
|
||||
stream: true,
|
||||
});
|
||||
}, actor)) as unknown as { stream: Readable };
|
||||
await drain(stream);
|
||||
expect(
|
||||
events.mock.calls.some(
|
||||
([key, data]) =>
|
||||
key === 'ai.prompt.cost-calculated' &&
|
||||
(data as { usage?: { output_tokens?: number } }).usage
|
||||
?.output_tokens === 2,
|
||||
),
|
||||
).toBe(true);
|
||||
await vi.waitFor(async () =>
|
||||
expect(await server.services.metering.getOutstandingHolds(actor)).toBe(0),
|
||||
);
|
||||
});
|
||||
|
||||
it('cuts off an upstream that outlives its caller by the SDK timeout', async () => {
|
||||
const actor = freeUser() as never;
|
||||
const metering = server.services.metering;
|
||||
const abort = new AbortController();
|
||||
let upstreamAborted = false;
|
||||
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce(
|
||||
streamOf(async ({ chatStream }) => {
|
||||
chatStream
|
||||
.message()
|
||||
.contentBlock({ type: 'text' })
|
||||
.addText('x'.repeat(400));
|
||||
const signal = Context.get('upstreamSignal')!;
|
||||
// A stalled upstream: no more chunks until it's cut off.
|
||||
await new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
upstreamAborted = true;
|
||||
reject(signal.reason);
|
||||
});
|
||||
});
|
||||
}) as never,
|
||||
);
|
||||
const metered = vi.spyOn(metering, 'batchIncrementUsages');
|
||||
const timers = vi.spyOn(globalThis, 'setTimeout');
|
||||
const { stream } = (await withTestActor(() => {
|
||||
Context.set('abortSignal', abort.signal);
|
||||
return driver.complete({
|
||||
model: 'priced',
|
||||
messages: [{ role: 'user', content: 'go' }],
|
||||
stream: true,
|
||||
});
|
||||
}, actor)) as unknown as { stream: Readable };
|
||||
|
||||
abort.abort();
|
||||
const limit = timers.mock.calls.find(
|
||||
([, ms]) => ms === SDK_TIMEOUT_MS,
|
||||
);
|
||||
expect(limit).toBeDefined();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(upstreamAborted).toBe(false);
|
||||
|
||||
(limit![0] as () => void)();
|
||||
await drain(stream);
|
||||
|
||||
expect(upstreamAborted).toBe(true);
|
||||
expect(
|
||||
meteredUsages(metered).some((u) =>
|
||||
u.usageType.endsWith('estimated_output_tokens'),
|
||||
),
|
||||
).toBe(true);
|
||||
await vi.waitFor(async () =>
|
||||
expect(await metering.getOutstandingHolds(actor)).toBe(0),
|
||||
);
|
||||
// What was generated before the hang-up is still charged.
|
||||
expect(metered).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ import {
|
||||
UNHEALTHY_TTL_SEC,
|
||||
} from './utils/providerHealth.js';
|
||||
import { CATALOG_TTL_MS } from './utils/cachedRemoteCatalog.js';
|
||||
import { SDK_TIMEOUT_MS } from './utils/sdkClient.js';
|
||||
import { fromFinishReason } from './utils/stopReason.js';
|
||||
import { AIChatStream } from './utils/Streaming.js';
|
||||
import {
|
||||
@@ -323,8 +324,17 @@ const isAuthFailure = (a: ProviderAttempt) =>
|
||||
a.status === 403 ||
|
||||
/unauthorized|forbidden|invalid api key/i.test(a.error));
|
||||
|
||||
/**
|
||||
* A new-api gateway's (Infron's) wrapper for an upstream that answered without
|
||||
* a JSON error body. It mirrors the upstream's status, so the 4xx says nothing
|
||||
* about the caller's request; a real vendor rejection carries its own message.
|
||||
*/
|
||||
const isOpaqueGatewayFailure = (a: ProviderAttempt) =>
|
||||
/bad response status code \d+(?:,\s*body:|$)/i.test(a.error);
|
||||
|
||||
const isUpstream5xx = (a: ProviderAttempt) =>
|
||||
(a.status !== undefined && a.status >= 500) ||
|
||||
isOpaqueGatewayFailure(a) ||
|
||||
/provider returned error|internal server error|service unavailable|bad gateway/i.test(
|
||||
a.error,
|
||||
);
|
||||
@@ -671,6 +681,10 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
// The caller hanging up stops nothing that has reached an upstream: it
|
||||
// would bill the request anyway, so it runs to its usage report.
|
||||
const upstream = new AbortController();
|
||||
Context.set('upstreamSignal', upstream.signal);
|
||||
// AI charges recorded in this request before the completion started.
|
||||
const priorCharges = requestAiCosts().length;
|
||||
|
||||
@@ -871,6 +885,8 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
if (!useFakeProvider) {
|
||||
await this.#resolvePuterPaths(attemptProvider, args, actor);
|
||||
}
|
||||
// Last point where a caller who left costs nothing.
|
||||
abortSignal?.throwIfAborted();
|
||||
return attemptProvider.complete(
|
||||
{
|
||||
...args,
|
||||
@@ -1033,10 +1049,20 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
const init = res.init_chat_stream;
|
||||
const cleanup = res.finally_fn;
|
||||
|
||||
// Providers also receive this signal to cancel while waiting for output.
|
||||
const abortStream = () => chatStream.abort();
|
||||
if (abortSignal?.aborted) abortStream();
|
||||
else abortSignal?.addEventListener('abort', abortStream);
|
||||
// A caller who leaves stops getting output, but the upstream keeps
|
||||
// going until it reports usage, with as long to finish as a
|
||||
// non-streaming completion gets.
|
||||
let detachedLimit: ReturnType<typeof setTimeout> | undefined;
|
||||
const detachStream = () => {
|
||||
chatStream.detach();
|
||||
detachedLimit = setTimeout(
|
||||
() => upstream.abort(),
|
||||
SDK_TIMEOUT_MS,
|
||||
);
|
||||
detachedLimit.unref?.();
|
||||
};
|
||||
if (abortSignal?.aborted) detachStream();
|
||||
else abortSignal?.addEventListener('abort', detachStream);
|
||||
|
||||
// Intercept `chatStream.end(usage)` to fire complete + cost events
|
||||
// (mirrors the non-streaming branch). Clone usage so providers that
|
||||
@@ -1060,9 +1086,9 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
enrichedUsage.cost = charged();
|
||||
}
|
||||
}
|
||||
// A cancelled stream isn't a completion, but whatever it
|
||||
// metered still gets its cost row.
|
||||
if (!chatStream.aborted) {
|
||||
// A completion nobody received isn't announced, but it still
|
||||
// gets its cost row.
|
||||
if (!chatStream.detached) {
|
||||
this.clients.event.emit(
|
||||
'ai.prompt.complete',
|
||||
{
|
||||
@@ -1129,13 +1155,13 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
(async () => {
|
||||
try {
|
||||
await init({ chatStream });
|
||||
if (!chatStream.aborted && !passthrough.writableEnded) {
|
||||
if (!passthrough.writableEnded) {
|
||||
throw new Error('Stream ended before completion');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!chatStream.aborted) {
|
||||
if (!chatStream.detached) {
|
||||
const attempt = toAttempt(model.id, model.provider!, e);
|
||||
passthrough.write(
|
||||
chatStream.write(
|
||||
`${JSON.stringify({
|
||||
type: 'error',
|
||||
message: sanitizeUpstreamMessage(
|
||||
@@ -1151,7 +1177,8 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
passthrough.end();
|
||||
} finally {
|
||||
clearInterval(renewHold);
|
||||
abortSignal?.removeEventListener('abort', abortStream);
|
||||
clearTimeout(detachedLimit);
|
||||
abortSignal?.removeEventListener('abort', detachStream);
|
||||
// Providers report usage the moment they meter it (see
|
||||
// `AIChatStream.reportUsage`); a stream that never got
|
||||
// there was never charged for.
|
||||
|
||||
@@ -25,10 +25,10 @@ import { AIChatStream } from '../utils/Streaming.js';
|
||||
import { FakeChatProvider } from './FakeChatProvider.js';
|
||||
|
||||
describe('fake chat streaming lifecycle', () => {
|
||||
it('cancels the simulated delay before writing output', async () => {
|
||||
it('cancels the simulated delay when the upstream signal aborts', async () => {
|
||||
await withTestActor(async () => {
|
||||
const abort = new AbortController();
|
||||
Context.set('abortSignal', abort.signal);
|
||||
Context.set('upstreamSignal', abort.signal);
|
||||
const result = await new FakeChatProvider().complete({
|
||||
model: 'fake',
|
||||
stream: true,
|
||||
@@ -59,7 +59,7 @@ describe('fake chat streaming lifecycle', () => {
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(events.map((event) => event.type)).toEqual(['text', 'usage']);
|
||||
});
|
||||
it('does not write after the chat stream was aborted independently', async () => {
|
||||
it('finishes without writing after the caller detaches', async () => {
|
||||
const result = await new FakeChatProvider().complete({
|
||||
model: 'fake',
|
||||
stream: true,
|
||||
@@ -67,8 +67,9 @@ describe('fake chat streaming lifecycle', () => {
|
||||
});
|
||||
const stream = new PassThrough();
|
||||
const chatStream = new AIChatStream({ stream });
|
||||
chatStream.abort();
|
||||
chatStream.detach();
|
||||
await result.init_chat_stream!({ chatStream });
|
||||
expect(stream.readableLength).toBe(0);
|
||||
expect(stream.writableEnded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -101,12 +101,11 @@ export class FakeChatProvider implements IChatProvider {
|
||||
chatStream: AIChatStream;
|
||||
}) => {
|
||||
await delay(500, undefined, {
|
||||
signal: Context.get('abortSignal'),
|
||||
signal: Context.get('upstreamSignal'),
|
||||
});
|
||||
const response = await resp;
|
||||
if (chatStream.aborted) return;
|
||||
await chatStream.waitForDrain();
|
||||
chatStream.stream.write(
|
||||
chatStream.write(
|
||||
`${JSON.stringify({
|
||||
type: 'text',
|
||||
text: response.message.content[0].text,
|
||||
|
||||
@@ -175,7 +175,7 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
}
|
||||
}
|
||||
const actor = Context.get('actor');
|
||||
const signal = Context.get('abortSignal');
|
||||
const signal = Context.get('upstreamSignal');
|
||||
|
||||
let messages = args.messages;
|
||||
if (
|
||||
|
||||
@@ -311,7 +311,7 @@ export class AzureChatProvider implements IChatProvider {
|
||||
|
||||
const completion = await this.#openAi.chat.completions.create(
|
||||
completionParams,
|
||||
{ signal: Context.get('abortSignal') },
|
||||
{ signal: Context.get('upstreamSignal') },
|
||||
);
|
||||
|
||||
return OpenAiUtil.handle_completion_output({
|
||||
|
||||
@@ -326,7 +326,7 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
|
||||
const completion = await this.#openAi.responses.create(
|
||||
completionParams,
|
||||
{ signal: Context.get('abortSignal') },
|
||||
{ signal: Context.get('upstreamSignal') },
|
||||
);
|
||||
return OpenAiUtil.handle_completion_output_responses_api({
|
||||
usage_calculator: ({
|
||||
|
||||
@@ -320,7 +320,7 @@ describe('ClaudeProvider.complete request shape', () => {
|
||||
};
|
||||
|
||||
it.each([false, true])(
|
||||
'passes cancellation to the SDK with stream=%s',
|
||||
'passes the upstream signal to the SDK with stream=%s',
|
||||
async (stream) => {
|
||||
const { provider } = makeProvider();
|
||||
const abort = new AbortController();
|
||||
@@ -328,7 +328,7 @@ describe('ClaudeProvider.complete request shape', () => {
|
||||
messagesStreamMock.mockReturnValueOnce(makeStreamLike([]));
|
||||
else messagesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
await withTestActor(() => {
|
||||
Context.set('abortSignal', abort.signal);
|
||||
Context.set('upstreamSignal', abort.signal);
|
||||
return provider.complete({
|
||||
model: 'claude-sonnet-4-6',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
|
||||
@@ -389,10 +389,10 @@ export class ClaudeProvider implements IChatProvider {
|
||||
if (args.stream) {
|
||||
const completion = usesBeta
|
||||
? this.anthropic.beta.messages.stream(sdkParams as never, {
|
||||
signal: Context.get('abortSignal'),
|
||||
signal: Context.get('upstreamSignal'),
|
||||
})
|
||||
: this.anthropic.messages.stream(sdkParams as never, {
|
||||
signal: Context.get('abortSignal'),
|
||||
signal: Context.get('upstreamSignal'),
|
||||
});
|
||||
// Subscribed before the request is awaited: the SDK only queues
|
||||
// events for iterators that already exist.
|
||||
@@ -432,10 +432,10 @@ export class ClaudeProvider implements IChatProvider {
|
||||
try {
|
||||
const msg = await (usesBeta
|
||||
? this.anthropic.beta.messages.create(sdkParams as never, {
|
||||
signal: Context.get('abortSignal'),
|
||||
signal: Context.get('upstreamSignal'),
|
||||
})
|
||||
: this.anthropic.messages.create(sdkParams as never, {
|
||||
signal: Context.get('abortSignal'),
|
||||
signal: Context.get('upstreamSignal'),
|
||||
}));
|
||||
const { usage, costs, details, advisorModel } = this.#meter(
|
||||
(msg as Message).usage,
|
||||
|
||||
@@ -337,11 +337,11 @@ describe('gemini-4-argon catalog and routing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call Google when the request was already cancelled', async () => {
|
||||
it('does not call Google when the upstream signal already aborted', async () => {
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
const error = await withTestActor(() => {
|
||||
Context.set('abortSignal', abort.signal);
|
||||
Context.set('upstreamSignal', abort.signal);
|
||||
return makeProvider().complete({
|
||||
model: 'gemini-4-argon',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
|
||||
@@ -213,12 +213,12 @@ describe('GroqAIProvider.complete request shape', () => {
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
};
|
||||
|
||||
it('passes the request cancellation signal to the SDK', async () => {
|
||||
it('passes the upstream signal to the SDK', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const abort = new AbortController();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
await withTestActor(() => {
|
||||
Context.set('abortSignal', abort.signal);
|
||||
Context.set('upstreamSignal', abort.signal);
|
||||
return provider.complete({
|
||||
model: 'openai/gpt-oss-20b',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
|
||||
@@ -229,19 +229,19 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
usage: { promptTokens: 1, completionTokens: 1 },
|
||||
};
|
||||
|
||||
it('passes the request cancellation signal to the SDK', async () => {
|
||||
it('passes the upstream signal to the SDK', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const abort = new AbortController();
|
||||
completeMock.mockResolvedValueOnce(baseCompletion);
|
||||
await withTestActor(() => {
|
||||
Context.set('abortSignal', abort.signal);
|
||||
Context.set('upstreamSignal', abort.signal);
|
||||
return provider.complete({
|
||||
model: 'mistral-small-latest',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
});
|
||||
});
|
||||
// Bounded by the SDK timeout as well, and still cancelled with the
|
||||
// request.
|
||||
// Bounded by the SDK timeout as well, and still aborted with the
|
||||
// upstream signal.
|
||||
const signal: AbortSignal = completeMock.mock.calls[0][1]?.signal;
|
||||
expect(signal.aborted).toBe(false);
|
||||
abort.abort();
|
||||
|
||||
@@ -243,8 +243,8 @@ export class MistralAIProvider implements IChatProvider {
|
||||
// signal is passed; a stream is bounded by its caller instead.
|
||||
{
|
||||
signal: stream
|
||||
? Context.get('abortSignal')
|
||||
: withSdkTimeout(Context.get('abortSignal')),
|
||||
? Context.get('upstreamSignal')
|
||||
: withSdkTimeout(Context.get('upstreamSignal')),
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -927,7 +927,7 @@ describe('OpenAiChatProvider.complete error mapping', () => {
|
||||
|
||||
describe('provider request cancellation', () => {
|
||||
it.each([false, true])(
|
||||
'passes cancellation to the SDK with stream=%s',
|
||||
'passes the upstream signal to the SDK with stream=%s',
|
||||
async (stream) => {
|
||||
const { provider } = makeProvider();
|
||||
const abort = new AbortController();
|
||||
@@ -945,7 +945,7 @@ describe('provider request cancellation', () => {
|
||||
},
|
||||
);
|
||||
await withTestActor(() => {
|
||||
Context.set('abortSignal', abort.signal);
|
||||
Context.set('upstreamSignal', abort.signal);
|
||||
return provider.complete({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
|
||||
@@ -250,7 +250,7 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
|
||||
const completion = await this.#openAi.chat.completions.create(
|
||||
completionParams,
|
||||
{ signal: Context.get('abortSignal') },
|
||||
{ signal: Context.get('upstreamSignal') },
|
||||
);
|
||||
|
||||
return OpenAiUtil.handle_completion_output({
|
||||
|
||||
@@ -307,7 +307,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
|
||||
const completion = await this.#openAi.responses.create(
|
||||
completionParams,
|
||||
{ signal: Context.get('abortSignal') },
|
||||
{ signal: Context.get('upstreamSignal') },
|
||||
);
|
||||
return OpenAiUtil.handle_completion_output_responses_api({
|
||||
usage_calculator: ({ usage, webSearchCalls, setUsageCosts }) => {
|
||||
|
||||
@@ -97,10 +97,10 @@ export class XAIProvider implements IChatProvider {
|
||||
}
|
||||
: {}),
|
||||
} as ChatCompletionCreateParams,
|
||||
{ signal: Context.get('abortSignal') },
|
||||
{ signal: Context.get('upstreamSignal') },
|
||||
);
|
||||
} catch (e) {
|
||||
if (!Context.get('abortSignal')?.aborted) {
|
||||
if (!Context.get('upstreamSignal')?.aborted) {
|
||||
console.log('XAI AI process_input_messages error: ', e);
|
||||
}
|
||||
throw e;
|
||||
|
||||
@@ -651,8 +651,9 @@ export const create_chat_stream_handler =
|
||||
}
|
||||
}
|
||||
|
||||
Context.get('abortSignal')?.throwIfAborted();
|
||||
if (!finish_reason && !last_usage) {
|
||||
// Some SDKs end an aborted stream as if it were complete.
|
||||
Context.get('upstreamSignal')?.throwIfAborted();
|
||||
throw new Error('Stream ended before completion');
|
||||
}
|
||||
|
||||
@@ -824,8 +825,11 @@ export const create_chat_stream_handler_responses_api =
|
||||
}
|
||||
}
|
||||
|
||||
Context.get('abortSignal')?.throwIfAborted();
|
||||
if (!completed) throw new Error('Stream ended before completion');
|
||||
if (!completed) {
|
||||
// Some SDKs end an aborted stream as if it were complete.
|
||||
Context.get('upstreamSignal')?.throwIfAborted();
|
||||
throw new Error('Stream ended before completion');
|
||||
}
|
||||
|
||||
// TODO DS: this is a bit too abstracted... this is basically just doing the metering now
|
||||
// Missing usage is left undefined rather than fed to the calculator —
|
||||
|
||||
@@ -1919,7 +1919,7 @@ describe('cancelled upstream iterators', () => {
|
||||
const harness = makeCapturingChatStream();
|
||||
await expect(
|
||||
withTestActor(() => {
|
||||
Context.set('abortSignal', abort.signal);
|
||||
Context.set('upstreamSignal', abort.signal);
|
||||
return handler({
|
||||
completion,
|
||||
usage_calculator: usageCalculator,
|
||||
@@ -1932,6 +1932,58 @@ describe('cancelled upstream iterators', () => {
|
||||
expect(usageCalculator).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
'chat completions',
|
||||
create_chat_stream_handler,
|
||||
[
|
||||
{ choices: [{ delta: { content: 'partial' } }] },
|
||||
{
|
||||
choices: [],
|
||||
usage: { prompt_tokens: 3, completion_tokens: 5 },
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
'responses',
|
||||
create_chat_stream_handler_responses_api,
|
||||
[
|
||||
{ type: 'response.output_text.delta', delta: 'partial' },
|
||||
{
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
status: 'completed',
|
||||
usage: { input_tokens: 3, output_tokens: 5 },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
])(
|
||||
'meters %s usage that arrived before the caller left',
|
||||
async (_name, handler, chunks) => {
|
||||
const abort = new AbortController();
|
||||
const usageCalculator = vi.fn(() => ({ output_tokens: 5 }));
|
||||
const completion = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield* chunks;
|
||||
abort.abort();
|
||||
},
|
||||
};
|
||||
const harness = makeCapturingChatStream();
|
||||
await withTestActor(() => {
|
||||
Context.set('abortSignal', abort.signal);
|
||||
return handler({
|
||||
completion,
|
||||
usage_calculator: usageCalculator,
|
||||
})({ chatStream: harness.chatStream });
|
||||
});
|
||||
expect(usageCalculator).toHaveBeenCalledTimes(1);
|
||||
expect(harness.chatStream.reportedUsage).toEqual({
|
||||
output_tokens: 5,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -169,9 +169,9 @@ export class AIChatStream {
|
||||
*/
|
||||
reportedUsage = null;
|
||||
|
||||
/** Set once the caller has gone away; see `abort`. */
|
||||
aborted = false;
|
||||
abortController = new AbortController();
|
||||
/** Set once the caller has gone away; see `detach`. */
|
||||
detached = false;
|
||||
#detachController = new AbortController();
|
||||
|
||||
/**
|
||||
* Stop/usage/context-management state for the end-of-stream `usage` line,
|
||||
@@ -199,28 +199,31 @@ export class AIChatStream {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop accepting output after cancellation; providers cancel their requests
|
||||
* too.
|
||||
* Stop writing output once the caller has gone away. The provider keeps
|
||||
* reading its upstream, so the usage the upstream reports still gets
|
||||
* metered.
|
||||
*/
|
||||
abort() {
|
||||
this.aborted = true;
|
||||
this.abortController.abort();
|
||||
detach() {
|
||||
this.detached = true;
|
||||
this.#detachController.abort();
|
||||
}
|
||||
|
||||
/** False once nothing will read the output. */
|
||||
#writable() {
|
||||
return !this.detached && !this.stream.destroyed;
|
||||
}
|
||||
|
||||
async waitForDrain() {
|
||||
this.#assertOpen();
|
||||
if (!this.stream.writableNeedDrain) return;
|
||||
if (!this.#writable() || !this.stream.writableNeedDrain) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const signal = this.#detachController.signal;
|
||||
const cleanup = () => {
|
||||
this.stream.removeListener('drain', onDrain);
|
||||
this.stream.removeListener('close', onAbort);
|
||||
this.stream.removeListener('drain', onDone);
|
||||
this.stream.removeListener('close', onDone);
|
||||
this.stream.removeListener('error', onError);
|
||||
this.abortController.signal.removeEventListener(
|
||||
'abort',
|
||||
onAbort,
|
||||
);
|
||||
signal.removeEventListener('abort', onDone);
|
||||
};
|
||||
const onDrain = () => {
|
||||
const onDone = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
@@ -228,28 +231,16 @@ export class AIChatStream {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const onAbort = () => onError(new ChatStreamAbortedError());
|
||||
this.stream.once('drain', onDrain);
|
||||
this.stream.once('close', onAbort);
|
||||
this.stream.once('drain', onDone);
|
||||
this.stream.once('close', onDone);
|
||||
this.stream.once('error', onError);
|
||||
this.abortController.signal.addEventListener('abort', onAbort, {
|
||||
once: true,
|
||||
});
|
||||
if (this.stream.destroyed || this.aborted) onAbort();
|
||||
else if (!this.stream.writableNeedDrain) onDrain();
|
||||
signal.addEventListener('abort', onDone, { once: true });
|
||||
if (!this.#writable() || !this.stream.writableNeedDrain) onDone();
|
||||
});
|
||||
this.#assertOpen();
|
||||
}
|
||||
|
||||
#assertOpen() {
|
||||
if (this.aborted) throw new ChatStreamAbortedError();
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
countOutput(text) {
|
||||
// Tool-call input arrives through here and is only written when the
|
||||
// block ends, so a long tool call has to notice the abort here too.
|
||||
this.#assertOpen();
|
||||
if (typeof text === 'string') this.outputChars += text.length;
|
||||
}
|
||||
|
||||
@@ -279,9 +270,8 @@ export class AIChatStream {
|
||||
* @param {{ alreadyCounted?: boolean }} [opts]
|
||||
*/
|
||||
writeChunk(chunk, { alreadyCounted = false } = {}) {
|
||||
this.#assertOpen();
|
||||
if (!alreadyCounted) this.outputChars += payloadChars(chunk);
|
||||
this.stream.write(`${JSON.stringify(chunk)}\n`);
|
||||
if (this.#writable()) this.stream.write(`${JSON.stringify(chunk)}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,8 +374,7 @@ export class AIChatStream {
|
||||
*/
|
||||
end(usage, extra) {
|
||||
this.reportUsage(usage);
|
||||
// Usage still counts after an abort; only the line has no reader.
|
||||
if (!this.aborted) {
|
||||
if (this.#writable()) {
|
||||
const stop = this.stop;
|
||||
const finish_reason = stop?.reason
|
||||
? toFinishReason(stop.reason)
|
||||
@@ -436,16 +425,7 @@ export class AIChatStream {
|
||||
return new AIChatMessageStream(this);
|
||||
}
|
||||
write(...args) {
|
||||
this.#assertOpen();
|
||||
return this.stream.write(...args);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown into a provider's read loop once its caller has gone away. */
|
||||
export class ChatStreamAbortedError extends Error {
|
||||
constructor() {
|
||||
super('Chat stream aborted: the caller disconnected');
|
||||
this.name = 'ChatStreamAbortedError';
|
||||
if (this.#writable()) this.stream.write(...args);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import Streaming, {
|
||||
AIChatStream,
|
||||
AIChatTextStream,
|
||||
AIChatToolUseStream,
|
||||
ChatStreamAbortedError,
|
||||
} from './Streaming.js';
|
||||
|
||||
// AIChatStream + friends emit newline-delimited JSON to an underlying
|
||||
@@ -189,26 +188,27 @@ describe('AIChatStream stop + usage-detail reporting', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Abort ──────────────────────────────────────────────────────────
|
||||
// -- Detach --
|
||||
|
||||
describe('AIChatStream.abort', () => {
|
||||
it('throws into the next text, reasoning or tool-call write', () => {
|
||||
describe('AIChatStream.detach', () => {
|
||||
it('stops writing but keeps counting output', () => {
|
||||
const h = makeHarness();
|
||||
const text = h.chatStream.message().contentBlock({ type: 'text' });
|
||||
const tool = h.chatStream
|
||||
.message()
|
||||
.contentBlock({ type: 'tool_use', id: 't', name: 'f' });
|
||||
h.chatStream.abort();
|
||||
expect(() => text.addText('x')).toThrow(ChatStreamAbortedError);
|
||||
expect(() => text.addReasoning('x')).toThrow(ChatStreamAbortedError);
|
||||
expect(() => tool.addPartialJSON('{')).toThrow(ChatStreamAbortedError);
|
||||
expect(() => h.chatStream.write('raw')).toThrow(ChatStreamAbortedError);
|
||||
h.chatStream.detach();
|
||||
text.addText('abc');
|
||||
text.addReasoning('de');
|
||||
tool.addPartialJSON('{}');
|
||||
h.chatStream.write('raw');
|
||||
expect(h.chatStream.outputChars).toBe(7);
|
||||
expect(h.rawChunks()).toEqual([]);
|
||||
});
|
||||
|
||||
it('still records usage and ends the stream, writing nothing', () => {
|
||||
const h = makeHarness();
|
||||
h.chatStream.abort();
|
||||
h.chatStream.detach();
|
||||
h.chatStream.end({ output_tokens: 3 });
|
||||
expect(h.chatStream.reportedUsage).toEqual({ output_tokens: 3 });
|
||||
expect(h.rawChunks()).toEqual([]);
|
||||
@@ -448,16 +448,25 @@ describe('AIChatStream backpressure', () => {
|
||||
sink.destroy();
|
||||
});
|
||||
|
||||
it.each(['abort', 'close', 'error'])('releases a drain wait on %s', async (event) => {
|
||||
it.each(['detach', 'close'])('releases a drain wait on %s', async (event) => {
|
||||
const sink = new Writable({ highWaterMark: 1, write() {} });
|
||||
const chatStream = new AIChatStream({ stream: sink });
|
||||
chatStream.writeChunk({ type: 'text', text: 'hello' });
|
||||
const waiting = chatStream.waitForDrain();
|
||||
const rejected = expect(waiting).rejects.toThrow(event === 'error' ? 'failed' : 'Chat stream aborted');
|
||||
if (event === 'abort') chatStream.abort();
|
||||
else if (event === 'error') sink.destroy(new Error('failed'));
|
||||
if (event === 'detach') chatStream.detach();
|
||||
else sink.destroy();
|
||||
await rejected;
|
||||
await waiting;
|
||||
expect(sink.listenerCount('drain')).toBe(0);
|
||||
await chatStream.waitForDrain();
|
||||
});
|
||||
|
||||
it('rejects a drain wait when the writable errors', async () => {
|
||||
const sink = new Writable({ highWaterMark: 1, write() {} });
|
||||
const chatStream = new AIChatStream({ stream: sink });
|
||||
chatStream.writeChunk({ type: 'text', text: 'hello' });
|
||||
const waiting = chatStream.waitForDrain();
|
||||
sink.destroy(new Error('failed'));
|
||||
await expect(waiting).rejects.toThrow('failed');
|
||||
expect(sink.listenerCount('drain')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -40,10 +40,13 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { SYSTEM_ACTOR } from '../../core/actor.js';
|
||||
import { type Actor, SYSTEM_ACTOR } from '../../core/actor.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { withAiCostFactorHook } from '../integrationTestUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './providers/cloudflare/models.js';
|
||||
import cloudflareModelSearchFixture from './providers/cloudflare/fixtures/model-search.json';
|
||||
import { GEMINI_IMAGE_GENERATION_MODELS } from './providers/gemini/models.js';
|
||||
@@ -981,6 +984,56 @@ describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
expect(result).toBe('https://rp/img.png');
|
||||
expect(createPredictionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('registers Kling from the shared `kling` key and routes its models there', async () => {
|
||||
const klingDriver = new ImageGenerationDriver(
|
||||
{
|
||||
providers: {
|
||||
'openai-image-generation': { apiKey: 'oai-key' },
|
||||
kling: { apiKey: 'kl-key' },
|
||||
},
|
||||
} as never,
|
||||
server.clients,
|
||||
server.stores,
|
||||
server.services,
|
||||
);
|
||||
await klingDriver.onServerStart();
|
||||
const logged = () =>
|
||||
eventEmitSpy.mock.calls.filter(([key]) => key === 'ai.log.image')
|
||||
.at(-1)?.[1];
|
||||
|
||||
await withActor(() =>
|
||||
klingDriver.generate({
|
||||
provider: 'kling',
|
||||
prompt: 'hi',
|
||||
test_mode: true,
|
||||
}),
|
||||
);
|
||||
expect(logged()).toMatchObject({
|
||||
model_used: 'kling-v3',
|
||||
service_used: 'kling-image-generation',
|
||||
});
|
||||
|
||||
await withActor(() =>
|
||||
klingDriver.generate({
|
||||
model: 'kling:kling/kling-v3-omni',
|
||||
prompt: 'hi',
|
||||
test_mode: true,
|
||||
}),
|
||||
);
|
||||
expect(logged()).toMatchObject({
|
||||
model_used: 'kling-v3-omni',
|
||||
service_used: 'kling-image-generation',
|
||||
});
|
||||
|
||||
// Kling registers after OpenAI, so it never becomes the default.
|
||||
await withActor(() =>
|
||||
klingDriver.generate({ prompt: 'hi', test_mode: true }),
|
||||
);
|
||||
expect(logged()).toMatchObject({
|
||||
service_used: 'openai-image-generation',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Ratio normalization ────────────────────────────────────────────
|
||||
@@ -1027,6 +1080,126 @@ describe('ImageGenerationDriver.generate ratio normalization', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Puter file image inputs ───────────────────────────────────────
|
||||
|
||||
describe('ImageGenerationDriver.generate Puter file inputs', () => {
|
||||
const PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
||||
|
||||
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
|
||||
const username = `image-media-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
created,
|
||||
);
|
||||
const user = (await server.stores.user.getById(created.id))!;
|
||||
return {
|
||||
userId: user.id,
|
||||
actor: {
|
||||
user: {
|
||||
id: user.id,
|
||||
uuid: user.uuid,
|
||||
username: user.username,
|
||||
email: user.email ?? null,
|
||||
email_confirmed: true,
|
||||
} as Actor['user'],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const writeImage = async (
|
||||
{ actor, userId }: { actor: Actor; userId: number },
|
||||
name: string,
|
||||
) => {
|
||||
const path = `/${actor.user.username}/${name}`;
|
||||
const bytes = Buffer.from(PNG_BASE64, 'base64');
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: bytes.byteLength,
|
||||
contentType: 'application/octet-stream',
|
||||
},
|
||||
fileContent: bytes,
|
||||
});
|
||||
return path;
|
||||
};
|
||||
|
||||
const generateAs = (actor: Actor, args: Record<string, unknown>) =>
|
||||
Promise.resolve(
|
||||
runWithContext({ actor }, () =>
|
||||
driver.generate({
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-pro-image',
|
||||
prompt: 'hi',
|
||||
...args,
|
||||
} as never),
|
||||
),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(server.services.metering, 'hasEnoughCredits').mockResolvedValue(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends Puter file images to the provider, leaving the caller's array and the audit log on the paths", async () => {
|
||||
const user = await makeUser();
|
||||
const path = await writeImage(user, 'input.tmp');
|
||||
const inputImages = [path];
|
||||
googleAIGenerateContentMock.mockResolvedValueOnce({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{ inlineData: { data: 'aW1n', mimeType: 'image/png' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await generateAs(user.actor, { input_images: inputImages });
|
||||
|
||||
const sent = JSON.stringify(
|
||||
googleAIGenerateContentMock.mock.calls[0]![0].contents,
|
||||
);
|
||||
expect(sent).toContain(PNG_BASE64);
|
||||
expect(sent).not.toContain(path);
|
||||
expect(inputImages).toEqual([path]);
|
||||
const [, log] = eventEmitSpy.mock.calls.find(
|
||||
([eventName]) => eventName === 'ai.log.image',
|
||||
)!;
|
||||
expect(
|
||||
(log as { parameters: { input_images: string[] } }).parameters
|
||||
.input_images,
|
||||
).toEqual([path]);
|
||||
expect(secureFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses another user's image before calling the provider", async () => {
|
||||
const owner = await makeUser();
|
||||
const intruder = await makeUser();
|
||||
const path = await writeImage(owner, 'private.png');
|
||||
const status = await generateAs(intruder.actor, {
|
||||
input_image: path,
|
||||
}).then(
|
||||
() => null,
|
||||
(err: { statusCode?: number }) => err.statusCode,
|
||||
);
|
||||
expect([403, 404]).toContain(status);
|
||||
expect(googleAIGenerateContentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Audit log ──────────────────────────────────────────────────────
|
||||
|
||||
describe('ImageGenerationDriver.generate audit log', () => {
|
||||
@@ -1473,3 +1646,34 @@ it.each([undefined, null, '', ' ', 42, {}])(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe('ImageGenerationDriver cost factor', () => {
|
||||
it('holds and meters at the AI cost factor', async () => {
|
||||
const reserveSpy = vi.spyOn(server.services.metering, 'reserveCredits');
|
||||
const usageSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
);
|
||||
const generate = async (charges: number) => {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://oai/img.png' }],
|
||||
});
|
||||
await withActor(() =>
|
||||
driver.generate({ model: 'gpt-image-2', prompt: 'hi' }),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(usageSpy).toHaveBeenCalledTimes(charges),
|
||||
);
|
||||
};
|
||||
await generate(1);
|
||||
await withAiCostFactorHook(server.clients.event, 2, () => generate(2));
|
||||
|
||||
const costs = (call: number) =>
|
||||
usageSpy.mock.calls[call]![1].map((u) => u.costOverride!);
|
||||
expect(costs(0).length).toBeGreaterThan(0);
|
||||
expect(costs(1)).toEqual(costs(0).map((c) => Math.round(c * 2)));
|
||||
const held = reserveSpy.mock.calls.map(([, amount]) => amount);
|
||||
expect(held[0]).toBeGreaterThan(0);
|
||||
expect(held).toEqual([held[0], Math.round(held[0]! * 2)]);
|
||||
});
|
||||
});
|
||||
@@ -32,12 +32,14 @@ import {
|
||||
resolveOutputPath,
|
||||
saveGeneratedMediaToFS,
|
||||
} from '../util/generatedMedia.js';
|
||||
import { readPuterImageInputs } from '../util/mediaInput.js';
|
||||
import { ModelCatalog } from '../util/modelCatalog.js';
|
||||
import { CATALOG_TTL_MS } from '../ai-chat/utils/cachedRemoteCatalog.js';
|
||||
import { readProviderKey } from '../util/providerRegistry.js';
|
||||
import { BytePlusImageProvider } from './providers/byteplus/BytePlusImageProvider.js';
|
||||
import { CloudflareImageProvider } from './providers/cloudflare/CloudflareImageProvider.js';
|
||||
import { GeminiImageProvider } from './providers/gemini/GeminiImageProvider.js';
|
||||
import { KlingImageProvider } from './providers/kling/KlingImageProvider.js';
|
||||
import { OpenAiImageProvider } from './providers/openai/OpenAiImageProvider.js';
|
||||
import { ReplicateImageGenerationProvider } from './providers/replicate/ReplicateImageGenerationProvider.js';
|
||||
import { resolveImageSize } from './imageDimensions.js';
|
||||
@@ -72,6 +74,7 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
'xai-image-generation',
|
||||
'replicate-image-generation',
|
||||
'byteplus-image-generation',
|
||||
'kling-image-generation',
|
||||
];
|
||||
readonly isDefault = true;
|
||||
|
||||
@@ -328,7 +331,12 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
{},
|
||||
);
|
||||
|
||||
const result = await provider.generate(request);
|
||||
// After the audit log, which records the paths rather than the bytes.
|
||||
const result = await provider.generate(
|
||||
request.test_mode
|
||||
? request
|
||||
: await this.#withPuterImagesInlined(actor, request),
|
||||
);
|
||||
|
||||
if (resolvedOutputPath) {
|
||||
await saveGeneratedMediaToFS(
|
||||
@@ -343,6 +351,34 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* `request` with image inputs given as Puter file paths read within the
|
||||
* caller's access and replaced by data URIs. `request` is left as is.
|
||||
*/
|
||||
async #withPuterImagesInlined(
|
||||
actor: Actor,
|
||||
request: IGenerateParams,
|
||||
): Promise<IGenerateParams> {
|
||||
const { input_image: inputImage, input_images: inputImages } = request;
|
||||
const inlined = await readPuterImageInputs(
|
||||
{ stores: this.stores, fs: this.services.fs },
|
||||
actor,
|
||||
[inputImage, request.maskImage, ...(inputImages ?? [])],
|
||||
);
|
||||
if (inlined.size === 0) return request;
|
||||
|
||||
const swap = <T>(value: T): T | string =>
|
||||
(typeof value === 'string' && inlined.get(value)) || value;
|
||||
return {
|
||||
...request,
|
||||
...(inputImage && { input_image: swap(inputImage) }),
|
||||
...(inputImages && { input_images: inputImages.map(swap) }),
|
||||
...(request.maskImage != null && {
|
||||
maskImage: swap(request.maskImage),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
#registerProviders() {
|
||||
const providers = this.config.providers ?? {};
|
||||
const m = this.#aiMetering;
|
||||
@@ -445,6 +481,20 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
m,
|
||||
);
|
||||
}
|
||||
|
||||
// One `kling` key covers video, image and TTS.
|
||||
const klingCfg = (providers['kling-image-generation'] ??
|
||||
providers['kling']) as Record<string, unknown> | undefined;
|
||||
const klingKey = readProviderKey(klingCfg);
|
||||
if (klingKey) {
|
||||
this.#providers['kling-image-generation'] = new KlingImageProvider(
|
||||
{
|
||||
apiKey: klingKey,
|
||||
apiBaseUrl: klingCfg?.apiBaseUrl as string | undefined,
|
||||
},
|
||||
m,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuilds the model map; overlapping callers share one rebuild. */
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Integration test for the Kling image provider.
|
||||
*
|
||||
* Generates one 1K image on kling-v2-1 ($0.014/image). Skipped when
|
||||
* `PUTER_TEST_AI_KLING_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { KlingImageProvider } from './KlingImageProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_KLING_API_KEY';
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'KlingImageProvider (integration)',
|
||||
() => {
|
||||
it(
|
||||
'generates an image URL from kling-v2-1',
|
||||
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const provider = new KlingImageProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
|
||||
const url = await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'kling-v2-1',
|
||||
prompt: 'a single red dot on a white background',
|
||||
quality: '1k',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(url.startsWith('https://')).toBe(true);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Offline unit tests for KlingImageProvider.
|
||||
*
|
||||
* Boots a real PuterServer and constructs the provider against the live wired
|
||||
* `MeteringService`. Kling's task API has no SDK; the provider calls it through
|
||||
* global `fetch`, which is stubbed as the network egress point.
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { withAiCostFactor } from '../../../util/aiCostFactor.js';
|
||||
import type { IGenerateParams } from '../../types.js';
|
||||
import { KlingImageProvider } from './KlingImageProvider.js';
|
||||
|
||||
// -- Test harness ----------------------------------------------------
|
||||
|
||||
let server: PuterServer;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeProvider = () =>
|
||||
new KlingImageProvider(
|
||||
{ apiKey: 'test-key', pollIntervalMs: 1 },
|
||||
withAiCostFactor(
|
||||
server.services.metering,
|
||||
server.clients.event,
|
||||
'ai-image',
|
||||
),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
incrementUsageSpy.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const envelope = (data: unknown, code = 0, status = 200) =>
|
||||
new Response(JSON.stringify({ code, message: 'msg', data }), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
const task = (status: string, extra: Record<string, unknown> = {}) => ({
|
||||
task_id: 'task-1',
|
||||
task_status: status,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const succeeded = task('succeed', {
|
||||
task_result: { images: [{ index: 0, url: 'https://kling.example/1.png' }] },
|
||||
});
|
||||
|
||||
/** Queue the create response, then each poll response. */
|
||||
const mockFlow = (...polls: Response[]) => {
|
||||
fetchSpy.mockResolvedValueOnce(envelope(task('submitted')));
|
||||
for (const poll of polls) fetchSpy.mockResolvedValueOnce(poll);
|
||||
};
|
||||
|
||||
const call = (i: number) => {
|
||||
const [url, init] = fetchSpy.mock.calls[i]!;
|
||||
return {
|
||||
url: String(url),
|
||||
method: (init as RequestInit).method,
|
||||
body: (init as RequestInit).body
|
||||
? JSON.parse((init as RequestInit).body as string)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const generate = (params: Partial<IGenerateParams>) =>
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'a red apple', ...params }),
|
||||
);
|
||||
|
||||
// -- Construction ----------------------------------------------------
|
||||
|
||||
describe('KlingImageProvider construction', () => {
|
||||
it('throws when no apiKey is supplied', () => {
|
||||
expect(
|
||||
() =>
|
||||
new KlingImageProvider(
|
||||
{ apiKey: '' },
|
||||
withAiCostFactor(
|
||||
server.services.metering,
|
||||
server.clients.event,
|
||||
'ai-image',
|
||||
),
|
||||
),
|
||||
).toThrow(/API key/i);
|
||||
});
|
||||
|
||||
it('defaults to Kling Image 3.0', () => {
|
||||
expect(makeProvider().getDefaultModel()).toBe('kling-v3');
|
||||
});
|
||||
});
|
||||
|
||||
// -- Request shape ---------------------------------------------------
|
||||
|
||||
describe('KlingImageProvider request', () => {
|
||||
it('creates a text-to-image task and returns the polled image URL', async () => {
|
||||
mockFlow(envelope(task('processing')), envelope(succeeded));
|
||||
|
||||
const url = await generate({});
|
||||
|
||||
expect(url).toBe('https://kling.example/1.png');
|
||||
const create = call(0);
|
||||
expect(create.url).toBe(
|
||||
'https://api-singapore.klingai.com/v1/images/generations',
|
||||
);
|
||||
expect(create.method).toBe('POST');
|
||||
expect(create.body).toEqual({
|
||||
model_name: 'kling-v3',
|
||||
prompt: 'a red apple',
|
||||
n: 1,
|
||||
resolution: '1k',
|
||||
aspect_ratio: '1:1',
|
||||
});
|
||||
expect(call(1).url).toBe(
|
||||
'https://api-singapore.klingai.com/v1/images/generations/task-1',
|
||||
);
|
||||
expect(call(1).method).toBe('GET');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
const headers = (fetchSpy.mock.calls[0]![1] as RequestInit)
|
||||
.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe('Bearer test-key');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['high', '2k'],
|
||||
['2K', '2k'],
|
||||
['low', '1k'],
|
||||
['ultra', '1k'],
|
||||
])('maps quality %s to the %s tier', async (quality, tier) => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ quality });
|
||||
expect(call(0).body.resolution).toBe(tier);
|
||||
});
|
||||
|
||||
it('takes 4k only on Image 3.0 Omni', async () => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ model: 'kling-v3-omni', quality: '4k' });
|
||||
expect(call(0).body.resolution).toBe('4k');
|
||||
|
||||
fetchSpy.mockReset();
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ model: 'kling-v3', quality: '4k' });
|
||||
expect(call(0).body.resolution).toBe('1k');
|
||||
});
|
||||
|
||||
it('snaps the requested size to the closest Kling aspect ratio', async () => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ imageSize: { w: 1920, h: 1000, kind: 'pixels' } });
|
||||
expect(call(0).body.aspect_ratio).toBe('16:9');
|
||||
|
||||
fetchSpy.mockReset();
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ imageSize: { w: 2, h: 3, kind: 'aspect' } });
|
||||
expect(call(0).body.aspect_ratio).toBe('2:3');
|
||||
});
|
||||
|
||||
it('sends a negative prompt only without a reference image', async () => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ negative_prompt: 'blur' });
|
||||
expect(call(0).body.negative_prompt).toBe('blur');
|
||||
|
||||
fetchSpy.mockReset();
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({
|
||||
negative_prompt: 'blur',
|
||||
input_image: 'https://example.com/a.png',
|
||||
});
|
||||
expect(call(0).body.negative_prompt).toBeUndefined();
|
||||
expect(call(0).body.image).toBe('https://example.com/a.png');
|
||||
});
|
||||
|
||||
it('sends a data URI reference image as raw base64', async () => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ input_images: ['data:image/png;base64,iVBORw0KGgo'] });
|
||||
expect(call(0).body.image).toBe('iVBORw0KGgo');
|
||||
});
|
||||
|
||||
it.each(['kling-v3-omni', 'kling-image-o1'])(
|
||||
'sends %s reference images to omni-image with an auto ratio',
|
||||
async (model) => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({
|
||||
model,
|
||||
input_images: [
|
||||
'https://example.com/a.png',
|
||||
'data:image/jpeg;base64,/9j/4AAQ',
|
||||
],
|
||||
});
|
||||
const create = call(0);
|
||||
expect(create.url).toBe(
|
||||
'https://api-singapore.klingai.com/v1/images/omni-image',
|
||||
);
|
||||
expect(create.body).toMatchObject({
|
||||
model_name: model,
|
||||
aspect_ratio: 'auto',
|
||||
image_list: [
|
||||
{ image: 'https://example.com/a.png' },
|
||||
{ image: '/9j/4AAQ' },
|
||||
],
|
||||
});
|
||||
expect(call(1).url).toBe(
|
||||
'https://api-singapore.klingai.com/v1/images/omni-image/task-1',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects more input images than the model takes before any fetch', async () => {
|
||||
await expect(
|
||||
generate({
|
||||
model: 'kling-v3',
|
||||
input_images: ['https://e.com/a.png', 'https://e.com/b.png'],
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the sample image in test mode without calling Kling', async () => {
|
||||
const url = await generate({ test_mode: true });
|
||||
expect(url).toMatch(/^https:\/\//);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a transient poll failure', async () => {
|
||||
mockFlow(
|
||||
envelope(undefined, 5000, 500),
|
||||
envelope(task('processing')),
|
||||
envelope(succeeded),
|
||||
);
|
||||
await expect(generate({})).resolves.toBe('https://kling.example/1.png');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Billing ---------------------------------------------------------
|
||||
|
||||
describe('KlingImageProvider billing', () => {
|
||||
it('meters one image at the tier rate', async () => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ model: 'kling-v3-omni', quality: '4k' });
|
||||
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
|
||||
const [, usageType, amount, cost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(usageType).toBe('kling-image-generation:kling-v3-omni:4k');
|
||||
expect(amount).toBe(1);
|
||||
expect(cost).toBe(5.6 * 1_000_000);
|
||||
});
|
||||
|
||||
it('bills Image 2.1 image-to-image at its own rate', async () => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({
|
||||
model: 'kling-v2-1',
|
||||
input_image: 'https://example.com/a.png',
|
||||
});
|
||||
const [, usageType, , cost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(usageType).toBe(
|
||||
'kling-image-generation:kling-v2-1:1k:image-to-image',
|
||||
);
|
||||
expect(cost).toBe(2.8 * 1_000_000);
|
||||
|
||||
incrementUsageSpy.mockClear();
|
||||
fetchSpy.mockReset();
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ model: 'kling-v2-1' });
|
||||
const [, textUsage, , textCost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(textUsage).toBe('kling-image-generation:kling-v2-1:1k');
|
||||
expect(textCost).toBe(1.4 * 1_000_000);
|
||||
});
|
||||
|
||||
it('rejects before calling Kling when credits run short', async () => {
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
await expect(generate({})).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a content-policy failure as moderation_flagged, unbilled', async () => {
|
||||
mockFlow(
|
||||
envelope(
|
||||
task('failed', {
|
||||
task_status_msg:
|
||||
'Triggering the content risk control of the platform',
|
||||
}),
|
||||
),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
code: 'moderation_flagged',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports any other failed task as upstream_failed, unbilled', async () => {
|
||||
mockFlow(
|
||||
envelope(task('failed', { task_status_msg: 'internal error' })),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* 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 { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { insufficientCreditsError } from '../../../../services/metering/enforcement.js';
|
||||
import type { AiMeteringService } from '../../../util/aiCostFactor.js';
|
||||
import { klingImageInput, klingRequest } from '../../../util/kling.js';
|
||||
import {
|
||||
CONTENT_FILTER_PATTERN,
|
||||
isTransientUpstreamError,
|
||||
sanitizeUpstreamMessage,
|
||||
} from '../../../util/upstreamErrors.js';
|
||||
import {
|
||||
closestAspectRatio,
|
||||
formatAspectRatio,
|
||||
} from '../../imageDimensions.js';
|
||||
import { assertImagePrompt } from '../../imageValidation.js';
|
||||
import type { IGenerateParams, IImageProvider } from '../../types.js';
|
||||
import {
|
||||
KLING_IMAGE_GENERATION_MODELS,
|
||||
type KlingImageModel,
|
||||
} from './models.js';
|
||||
|
||||
const DEFAULT_MODEL = 'kling-v3';
|
||||
const MAX_PROMPT_CHARS = 2500;
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
const POLL_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
type KlingImageConfig = {
|
||||
apiKey: string;
|
||||
apiBaseUrl?: string;
|
||||
/** Test hook: polling cadence for task status checks. */
|
||||
pollIntervalMs?: number;
|
||||
};
|
||||
|
||||
interface KlingImageTask {
|
||||
task_id: string;
|
||||
task_status: 'submitted' | 'processing' | 'succeed' | 'failed';
|
||||
task_status_msg?: string;
|
||||
task_result?: { images?: Array<{ url?: string }> };
|
||||
}
|
||||
|
||||
const badRequest = (message: string) =>
|
||||
new HttpError(400, message, { legacyCode: 'bad_request' });
|
||||
|
||||
/**
|
||||
* Kling AI image generation (Image 3.0, 3.0 Omni, O1, 2.1).
|
||||
*
|
||||
* Kling's image API is task-based: the create call returns a task id, which is
|
||||
* polled until it settles. Billed per image at the resolution tier's rate.
|
||||
* https://kling.ai/document-api/api/image/3-0-omni/image-generation
|
||||
*/
|
||||
export class KlingImageProvider implements IImageProvider {
|
||||
#apiKey: string;
|
||||
#baseUrl: string | undefined;
|
||||
#pollIntervalMs: number;
|
||||
#meteringService: AiMeteringService;
|
||||
|
||||
constructor(config: KlingImageConfig, meteringService: AiMeteringService) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error('Kling image generation requires an API key');
|
||||
}
|
||||
this.#apiKey = config.apiKey;
|
||||
this.#baseUrl = config.apiBaseUrl?.replace(/\/$/, '');
|
||||
this.#pollIntervalMs = config.pollIntervalMs ?? POLL_INTERVAL_MS;
|
||||
this.#meteringService = meteringService;
|
||||
}
|
||||
|
||||
models(): KlingImageModel[] {
|
||||
return KLING_IMAGE_GENERATION_MODELS;
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
async generate(params: IGenerateParams): Promise<string> {
|
||||
const {
|
||||
prompt,
|
||||
test_mode: testMode,
|
||||
quality,
|
||||
negative_prompt,
|
||||
} = params;
|
||||
const model = this.#getModel(params.model);
|
||||
|
||||
if (testMode) {
|
||||
return 'https://puter-sample-data.puter.site/image_example.png';
|
||||
}
|
||||
|
||||
assertImagePrompt(prompt);
|
||||
if (prompt.length > MAX_PROMPT_CHARS) {
|
||||
throw badRequest(
|
||||
`Kling takes a prompt of at most ${MAX_PROMPT_CHARS} characters`,
|
||||
);
|
||||
}
|
||||
|
||||
const inputs = (
|
||||
params.input_images?.length
|
||||
? params.input_images
|
||||
: params.input_image
|
||||
? [params.input_image]
|
||||
: []
|
||||
).map(klingImageInput);
|
||||
if (inputs.length > model.maxInputImages) {
|
||||
throw badRequest(
|
||||
`${model.id} accepts at most ${model.maxInputImages} input image(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
const tier = this.#tier(quality, model);
|
||||
const costKey =
|
||||
inputs.length > 0 && model.costs[`${tier}:image-to-image`]
|
||||
? `${tier}:image-to-image`
|
||||
: tier;
|
||||
const cents = model.costs[costKey];
|
||||
const usageType = `kling-image-generation:${model.id}:${costKey}`;
|
||||
|
||||
const actor = Context.get('actor');
|
||||
if (!actor) {
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
}
|
||||
const hold = await this.#meteringService.reserveAiCredits(
|
||||
actor,
|
||||
usageType,
|
||||
Math.round(cents * 1_000_000),
|
||||
);
|
||||
if (!hold) throw insufficientCreditsError();
|
||||
|
||||
try {
|
||||
const aspectRatio = params.imageSize
|
||||
? formatAspectRatio(
|
||||
closestAspectRatio(
|
||||
params.imageSize,
|
||||
model.allowedRatios!,
|
||||
),
|
||||
)
|
||||
: inputs.length > 0 && model.autoRatio
|
||||
? 'auto'
|
||||
: '1:1';
|
||||
const body: Record<string, unknown> = {
|
||||
model_name: model.id,
|
||||
prompt,
|
||||
n: 1,
|
||||
resolution: tier,
|
||||
aspect_ratio: aspectRatio,
|
||||
};
|
||||
if (model.endpoint === 'omni-image') {
|
||||
if (inputs.length > 0) {
|
||||
body.image_list = inputs.map((image) => ({ image }));
|
||||
}
|
||||
} else if (inputs.length > 0) {
|
||||
body.image = inputs[0];
|
||||
} else if (
|
||||
typeof negative_prompt === 'string' &&
|
||||
negative_prompt.trim()
|
||||
) {
|
||||
// Kling takes a negative prompt only without a reference image.
|
||||
body.negative_prompt = negative_prompt;
|
||||
}
|
||||
|
||||
const path = `/v1/images/${model.endpoint}`;
|
||||
const created = await klingRequest<KlingImageTask>(
|
||||
this.#apiKey,
|
||||
'POST',
|
||||
path,
|
||||
{ body, baseUrl: this.#baseUrl },
|
||||
);
|
||||
const task = await this.#pollUntilSettled(path, created);
|
||||
if (task.task_status !== 'succeed') {
|
||||
throw this.#taskFailure(task.task_status_msg);
|
||||
}
|
||||
const url = task.task_result?.images?.[0]?.url;
|
||||
if (!url) {
|
||||
throw new Error('Kling response did not include an image URL');
|
||||
}
|
||||
|
||||
await this.#meteringService.incrementUsage(
|
||||
actor,
|
||||
usageType,
|
||||
1,
|
||||
Math.round(cents * 1_000_000),
|
||||
);
|
||||
return url;
|
||||
} finally {
|
||||
await hold.release();
|
||||
}
|
||||
}
|
||||
|
||||
async #pollUntilSettled(
|
||||
path: string,
|
||||
created: KlingImageTask,
|
||||
): Promise<KlingImageTask> {
|
||||
const signal = Context.get('abortSignal') as AbortSignal | undefined;
|
||||
const deadline = Date.now() + POLL_WINDOW_MS;
|
||||
let task = created;
|
||||
while (
|
||||
task.task_status === 'submitted' ||
|
||||
task.task_status === 'processing'
|
||||
) {
|
||||
if (signal?.aborted) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Client disconnected before image generation finished',
|
||||
{
|
||||
legacyCode: 'client_aborted',
|
||||
fields: { provider: 'kling' },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
throw new HttpError(
|
||||
504,
|
||||
'Timed out waiting for Kling image generation to complete',
|
||||
{
|
||||
legacyCode: 'upstream_timeout',
|
||||
fields: { provider: 'kling' },
|
||||
},
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, this.#pollIntervalMs),
|
||||
);
|
||||
try {
|
||||
task = await klingRequest<KlingImageTask>(
|
||||
this.#apiKey,
|
||||
'GET',
|
||||
`${path}/${encodeURIComponent(created.task_id)}`,
|
||||
{ baseUrl: this.#baseUrl },
|
||||
);
|
||||
} catch (err) {
|
||||
// A missed poll is not a failed task.
|
||||
if (!isTransientUpstreamError(err)) throw err;
|
||||
}
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* A content-policy refusal is the caller's to act on; anything else
|
||||
* Kling's.
|
||||
*/
|
||||
#taskFailure(message: string | undefined): HttpError {
|
||||
const detail =
|
||||
sanitizeUpstreamMessage(message ?? '') ||
|
||||
'Kling image generation failed';
|
||||
const fields = { provider: 'kling' };
|
||||
if (CONTENT_FILTER_PATTERN.test(detail)) {
|
||||
return new HttpError(400, detail, {
|
||||
legacyCode: 'bad_request',
|
||||
code: 'moderation_flagged',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
return new HttpError(502, detail, {
|
||||
legacyCode: 'upstream_failed',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
|
||||
/** Kling's `1k` / `2k` / `4k`, accepting other providers' quality names. */
|
||||
#tier(quality: string | undefined, model: KlingImageModel): string {
|
||||
const allowed = model.allowedQualityLevels!;
|
||||
const wanted = (quality ?? '').trim().toLowerCase();
|
||||
const synonym: Record<string, string> = {
|
||||
low: '1k',
|
||||
medium: '1k',
|
||||
high: '2k',
|
||||
hd: '2k',
|
||||
};
|
||||
const tier = allowed.includes(wanted) ? wanted : synonym[wanted];
|
||||
return tier && allowed.includes(tier) ? tier : allowed[0];
|
||||
}
|
||||
|
||||
#getModel(model?: string): KlingImageModel {
|
||||
const wanted = (model ?? '').trim().toLowerCase();
|
||||
return (
|
||||
KLING_IMAGE_GENERATION_MODELS.find(
|
||||
(m) =>
|
||||
m.id === wanted ||
|
||||
m.puterId === wanted ||
|
||||
m.aliases?.includes(wanted),
|
||||
) ??
|
||||
KLING_IMAGE_GENERATION_MODELS.find((m) => m.id === DEFAULT_MODEL)!
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { IImageModel } from '../../types.js';
|
||||
|
||||
export type KlingImageModel = IImageModel & {
|
||||
/** `generations` takes one reference image; `omni-image` takes several. */
|
||||
endpoint: 'generations' | 'omni-image';
|
||||
maxInputImages: number;
|
||||
/** Whether `aspect_ratio: 'auto'` may be sent with input images. */
|
||||
autoRatio: boolean;
|
||||
};
|
||||
|
||||
const RATIOS = [
|
||||
{ w: 16, h: 9 },
|
||||
{ w: 9, h: 16 },
|
||||
{ w: 1, h: 1 },
|
||||
{ w: 4, h: 3 },
|
||||
{ w: 3, h: 4 },
|
||||
{ w: 3, h: 2 },
|
||||
{ w: 2, h: 3 },
|
||||
{ w: 21, h: 9 },
|
||||
];
|
||||
|
||||
// usd-cents per image from https://kling.ai/document-api/pricing/base/image
|
||||
// (1 unit = $0.0035). Cost keys are the output resolution tier; Image 2.1
|
||||
// prices text-to-image and image-to-image apart. Capabilities per
|
||||
// https://kling.ai/document-api/guides/capability-map/image.
|
||||
export const KLING_IMAGE_GENERATION_MODELS: KlingImageModel[] = [
|
||||
{
|
||||
id: 'kling-v3',
|
||||
puterId: 'kling:kling/kling-v3',
|
||||
aliases: ['kling/kling-v3', 'kling-image-3.0'],
|
||||
name: 'Kling Image 3.0',
|
||||
version: '3.0',
|
||||
costs_currency: 'usd-cents',
|
||||
pricing_unit: 'per-tier',
|
||||
index_cost_key: '1k',
|
||||
costs: { '1k': 2.8, '2k': 2.8 },
|
||||
allowedQualityLevels: ['1k', '2k'],
|
||||
allowedRatios: RATIOS,
|
||||
endpoint: 'generations',
|
||||
maxInputImages: 1,
|
||||
autoRatio: false,
|
||||
},
|
||||
{
|
||||
id: 'kling-v3-omni',
|
||||
puterId: 'kling:kling/kling-v3-omni',
|
||||
aliases: ['kling/kling-v3-omni', 'kling-image-3.0-omni'],
|
||||
name: 'Kling Image 3.0 Omni',
|
||||
version: '3.0',
|
||||
costs_currency: 'usd-cents',
|
||||
pricing_unit: 'per-tier',
|
||||
index_cost_key: '1k',
|
||||
costs: { '1k': 2.8, '2k': 2.8, '4k': 5.6 },
|
||||
allowedQualityLevels: ['1k', '2k', '4k'],
|
||||
allowedRatios: RATIOS,
|
||||
endpoint: 'omni-image',
|
||||
maxInputImages: 10,
|
||||
autoRatio: true,
|
||||
},
|
||||
{
|
||||
id: 'kling-image-o1',
|
||||
puterId: 'kling:kling/kling-image-o1',
|
||||
aliases: ['kling/kling-image-o1'],
|
||||
name: 'Kling Image O1',
|
||||
version: 'o1',
|
||||
costs_currency: 'usd-cents',
|
||||
pricing_unit: 'per-tier',
|
||||
index_cost_key: '1k',
|
||||
costs: { '1k': 2.8, '2k': 2.8 },
|
||||
allowedQualityLevels: ['1k', '2k'],
|
||||
allowedRatios: RATIOS,
|
||||
endpoint: 'omni-image',
|
||||
maxInputImages: 10,
|
||||
autoRatio: true,
|
||||
},
|
||||
{
|
||||
id: 'kling-v2-1',
|
||||
puterId: 'kling:kling/kling-v2-1',
|
||||
aliases: ['kling/kling-v2-1', 'kling-image-2.1'],
|
||||
name: 'Kling Image 2.1',
|
||||
version: '2.1',
|
||||
costs_currency: 'usd-cents',
|
||||
pricing_unit: 'per-tier',
|
||||
index_cost_key: '1k',
|
||||
costs: {
|
||||
'1k': 1.4,
|
||||
'2k': 1.4,
|
||||
'1k:image-to-image': 2.8,
|
||||
'2k:image-to-image': 2.8,
|
||||
},
|
||||
allowedQualityLevels: ['1k', '2k'],
|
||||
allowedRatios: RATIOS,
|
||||
endpoint: 'generations',
|
||||
maxInputImages: 1,
|
||||
autoRatio: false,
|
||||
},
|
||||
];
|
||||
@@ -27,6 +27,10 @@ import { Mistral } from '@mistralai/mistralai';
|
||||
import { Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
awsCredentialsFrom,
|
||||
type AwsCredentials,
|
||||
} from '../../util/awsCredentials.js';
|
||||
import { mimeFromName } from '../../util/fileSigning.js';
|
||||
import {
|
||||
assertActorHasCredits,
|
||||
@@ -293,11 +297,7 @@ export class OCRDriver extends PuterDriver {
|
||||
|
||||
// Textract state — one client per region.
|
||||
#textractClients: Record<string, TextractClient> = {};
|
||||
#awsConfig: {
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
region?: string;
|
||||
} | null = null;
|
||||
#awsConfig: { credentials: AwsCredentials; region: string } | null = null;
|
||||
/** DetectDocumentText rates per region, from AWS's public price list. */
|
||||
#textractRates = textractRates(OCR_COSTS[TEXTRACT_PAGE_USAGE_TYPE]);
|
||||
|
||||
@@ -314,16 +314,14 @@ export class OCRDriver extends PuterDriver {
|
||||
Record<string, unknown> | undefined;
|
||||
const textractAws = (textract?.aws ?? textract) as
|
||||
Record<string, unknown> | undefined;
|
||||
const textractAccessKey = textractAws?.access_key as string | undefined;
|
||||
const textractSecretKey = textractAws?.secret_key as string | undefined;
|
||||
const textractCredentials = awsCredentialsFrom(textractAws);
|
||||
const textractRegion =
|
||||
(textractAws?.region as string | undefined) ??
|
||||
(textract?.region as string | undefined) ??
|
||||
DEFAULT_TEXTRACT_REGION;
|
||||
if (textractAccessKey && textractSecretKey) {
|
||||
if (textractCredentials) {
|
||||
this.#awsConfig = {
|
||||
accessKeyId: textractAccessKey,
|
||||
secretAccessKey: textractSecretKey,
|
||||
credentials: textractCredentials,
|
||||
region: textractRegion,
|
||||
};
|
||||
// Warm the configured region so the cost report has its rate.
|
||||
@@ -460,10 +458,7 @@ export class OCRDriver extends PuterDriver {
|
||||
const cached = this.#textractClients[region];
|
||||
if (cached) return cached;
|
||||
const client = new TextractClient({
|
||||
credentials: {
|
||||
accessKeyId: this.#awsConfig!.accessKeyId!,
|
||||
secretAccessKey: this.#awsConfig!.secretAccessKey!,
|
||||
},
|
||||
credentials: this.#awsConfig!.credentials,
|
||||
region,
|
||||
});
|
||||
this.#textractClients[region] = client;
|
||||
|
||||
@@ -48,7 +48,11 @@ import type { MeteringService } from '../../services/metering/MeteringService.js
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import type { VoiceChangerDriver } from './VoiceChangerDriver.js';
|
||||
import { callerError, readStreamResult } from '../integrationTestUtil.js';
|
||||
import {
|
||||
callerError,
|
||||
readStreamResult,
|
||||
withAiCostFactorHook,
|
||||
} from '../integrationTestUtil.js';
|
||||
import { VOICE_CHANGER_COSTS } from './costs.js';
|
||||
|
||||
const { webFetchMock } = vi.hoisted(() => ({ webFetchMock: vi.fn() }));
|
||||
@@ -352,6 +356,35 @@ describe('VoiceChangerDriver.convert success path', () => {
|
||||
expect(cost).toBe(perSecond * expectedSeconds);
|
||||
});
|
||||
|
||||
it('holds and meters at the AI cost factor', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const usageType = 'elevenlabs:eleven_multilingual_sts_v2:second';
|
||||
const metered = () =>
|
||||
incrementUsageSpy.mock.calls
|
||||
.filter(([, type]) => type === usageType)
|
||||
.map(([, , , cost]) => cost);
|
||||
const convert = async (charges: number) => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
okResponse(new ArrayBuffer(8), 'audio/mpeg'),
|
||||
);
|
||||
await readStreamResult(
|
||||
await withActor(actor, () =>
|
||||
driver.convert({
|
||||
audio: dataUrl(Buffer.alloc(32_000), 'audio/mpeg'),
|
||||
}),
|
||||
),
|
||||
);
|
||||
await vi.waitFor(() => expect(metered()).toHaveLength(charges));
|
||||
};
|
||||
await convert(1);
|
||||
await withAiCostFactorHook(server.clients.event, 2, () => convert(2));
|
||||
|
||||
const [base, factored] = metered();
|
||||
expect(factored).toBe(base! * 2);
|
||||
const held = hasCreditsSpy.mock.calls.map(([, amount]) => amount);
|
||||
expect(held).toEqual([base, base! * 2]);
|
||||
});
|
||||
|
||||
it('meters nothing when ElevenLabs breaks off before any audio', async () => {
|
||||
const { actor } = await makeUser();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
|
||||
@@ -49,6 +49,7 @@ import { PuterServer } from '../../server.js';
|
||||
import type { MeteringService } from '../../services/metering/MeteringService.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { withAiCostFactorHook } from '../integrationTestUtil.js';
|
||||
import type { SpeechToTextDriver } from './SpeechToTextDriver.js';
|
||||
import { SPEECH_TO_TEXT_COSTS } from './costs.js';
|
||||
|
||||
@@ -682,6 +683,34 @@ describe('SpeechToTextDriver metering', () => {
|
||||
const creditCall = hasCreditsSpy.mock.calls[0]!;
|
||||
expect(creditCall[1]).toBe(expected);
|
||||
});
|
||||
|
||||
it('holds and meters at the AI cost factor', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const usageType = 'openai:gpt-transcribe:second';
|
||||
const metered = () =>
|
||||
incrementUsageSpy.mock.calls
|
||||
.filter(([, type]) => type === usageType)
|
||||
.map(([, , , cost]) => cost);
|
||||
const transcribe = async (charges: number) => {
|
||||
transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
|
||||
await withActor(actor, () =>
|
||||
driver.transcribe({
|
||||
file: dataUrl(Buffer.alloc(32000, 0), 'audio/mp3'),
|
||||
model: 'gpt-transcribe',
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(metered()).toHaveLength(charges));
|
||||
};
|
||||
await transcribe(1);
|
||||
await withAiCostFactorHook(server.clients.event, 2, () =>
|
||||
transcribe(2),
|
||||
);
|
||||
|
||||
const [base, factored] = metered();
|
||||
expect(factored).toBe(base! * 2);
|
||||
const held = hasCreditsSpy.mock.calls.map(([, amount]) => amount);
|
||||
expect(held).toEqual([base, base! * 2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error paths ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,6 +44,10 @@ import { runWithContext } from '../../core/context.js';
|
||||
import { SYSTEM_ACTOR } from '../../core/actor.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import {
|
||||
readStreamResult,
|
||||
withAiCostFactorHook,
|
||||
} from '../integrationTestUtil.js';
|
||||
import type { TTSDriver } from './TTSDriver.js';
|
||||
import pollyPriceList from './providers/awsPolly/fixtures/us-west-2.json';
|
||||
|
||||
@@ -130,6 +134,7 @@ beforeAll(async () => {
|
||||
// from; TTS reads the same ones.
|
||||
xai: { secret_key: 'xai-key' },
|
||||
speechify: { apiKey: 'speechify-key' },
|
||||
kling: { apiKey: 'kling-key' },
|
||||
},
|
||||
} as never);
|
||||
driver = server.drivers.aiTts as unknown as TTSDriver;
|
||||
@@ -200,6 +205,7 @@ describe('TTSDriver provider registration', () => {
|
||||
'aws-polly',
|
||||
'elevenlabs',
|
||||
'gemini',
|
||||
'kling',
|
||||
'openai',
|
||||
'speechify',
|
||||
'xai',
|
||||
@@ -330,6 +336,42 @@ describe('TTSDriver.synthesize provider routing', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['kling', 'kling-tts', 'kling-ai'])(
|
||||
'resolves the %s alias to the kling provider',
|
||||
async (provider) => {
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(
|
||||
Response.json({
|
||||
code: 0,
|
||||
data: {
|
||||
task_status: 'succeed',
|
||||
task_result: {
|
||||
audios: [
|
||||
{ url: 'https://cdn.kling.example/a.mp3' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response('audio'));
|
||||
|
||||
await withActor(() => driver.synthesize({ text: 'hi', provider }));
|
||||
|
||||
expect(String(fetchSpy.mock.calls[0]![0])).toBe(
|
||||
'https://api-singapore.klingai.com/v1/audio/tts',
|
||||
);
|
||||
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('routes via legacy driverAlias (kling-tts → kling)', async () => {
|
||||
const voices = await withDriverName('kling-tts', () =>
|
||||
driver.list_voices(),
|
||||
);
|
||||
expect(voices.length).toBeGreaterThan(0);
|
||||
expect(voices.every((v) => v.provider === 'kling')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves provider aliases', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response('audio', {
|
||||
@@ -581,6 +623,7 @@ describe('TTSDriver list_voices / list_engines', () => {
|
||||
'gemini-3.8-flash-tts', // gemini
|
||||
'xai-tts',
|
||||
'simba-3.2', // speechify
|
||||
'kling-tts',
|
||||
'standard', // aws-polly
|
||||
]),
|
||||
);
|
||||
@@ -611,6 +654,7 @@ describe('TTSDriver.getReportedCosts', () => {
|
||||
'driver:aiTts/gemini',
|
||||
'driver:aiTts/xai',
|
||||
'driver:aiTts/speechify',
|
||||
'driver:aiTts/kling',
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -648,3 +692,40 @@ describe('TTSDriver.synthesize error mapping', () => {
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TTSDriver cost factor', () => {
|
||||
it('holds and meters at the AI cost factor', async () => {
|
||||
const incrementUsageSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'incrementUsage',
|
||||
);
|
||||
const hasCreditsSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'hasEnoughCredits',
|
||||
);
|
||||
const metered = () =>
|
||||
incrementUsageSpy.mock.calls.map(([, , , cost]) => cost!);
|
||||
const synthesize = async (charges: number) => {
|
||||
openaiSpeechCreateMock.mockResolvedValueOnce(openaiAudioResponse());
|
||||
await readStreamResult(
|
||||
await withActor(() =>
|
||||
driver.synthesize({
|
||||
text: 'hello there',
|
||||
provider: 'openai',
|
||||
}),
|
||||
),
|
||||
);
|
||||
await vi.waitFor(() => expect(metered()).toHaveLength(charges));
|
||||
};
|
||||
await synthesize(1);
|
||||
await withAiCostFactorHook(server.clients.event, 2, () =>
|
||||
synthesize(2),
|
||||
);
|
||||
|
||||
const [base, factored] = metered();
|
||||
expect(base).toBeGreaterThan(0);
|
||||
expect(factored).toBe(Math.round(base! * 2));
|
||||
const held = hasCreditsSpy.mock.calls.map(([, amount]) => amount);
|
||||
expect(held).toEqual([base, Math.round(base! * 2)]);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { awsCredentialsFrom } from '../../util/awsCredentials.js';
|
||||
import type { DriverStreamResult } from '../meta.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ import { TTS_CATALOG, TTS_DRIVER_ALIASES } from './providerAliases.js';
|
||||
import { AWSPollyTTSProvider } from './providers/awsPolly/AWSPollyTTSProvider.js';
|
||||
import { ElevenLabsTTSProvider } from './providers/elevenlabs/ElevenLabsTTSProvider.js';
|
||||
import { GeminiTTSProvider } from './providers/gemini/GeminiTTSProvider.js';
|
||||
import { KlingTTSProvider } from './providers/kling/KlingTTSProvider.js';
|
||||
import { OpenAITTSProvider } from './providers/openai/OpenAITTSProvider.js';
|
||||
import { SpeechifyTTSProvider } from './providers/speechify/SpeechifyTTSProvider.js';
|
||||
import { XAITTSProvider } from './providers/xai/XAITTSProvider.js';
|
||||
@@ -240,15 +242,13 @@ export class TTSDriver extends PuterDriver {
|
||||
const polly = providers['aws-polly'];
|
||||
const pollyAws = (polly?.aws ?? polly) as
|
||||
Record<string, unknown> | undefined;
|
||||
const pollyAccessKey = pollyAws?.access_key as string | undefined;
|
||||
const pollySecretKey = pollyAws?.secret_key as string | undefined;
|
||||
if (pollyAccessKey && pollySecretKey) {
|
||||
const pollyCredentials = awsCredentialsFrom(pollyAws);
|
||||
if (pollyCredentials) {
|
||||
register(
|
||||
'aws-polly',
|
||||
() =>
|
||||
new AWSPollyTTSProvider(m, {
|
||||
access_key: pollyAccessKey,
|
||||
secret_key: pollySecretKey,
|
||||
credentials: pollyCredentials,
|
||||
region: (pollyAws?.region ?? polly?.region) as
|
||||
string | undefined,
|
||||
}),
|
||||
@@ -279,5 +279,17 @@ export class TTSDriver extends PuterDriver {
|
||||
providers['speechify-tts'],
|
||||
),
|
||||
);
|
||||
// One `kling` key covers video, image and TTS.
|
||||
register('kling', () =>
|
||||
keyed(
|
||||
(apiKey) =>
|
||||
new KlingTTSProvider(m, {
|
||||
apiKey,
|
||||
apiBaseUrl: providers['kling']?.apiBaseUrl as
|
||||
string | undefined,
|
||||
}),
|
||||
providers['kling'],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export const TTS_PROVIDERS = [
|
||||
'gemini',
|
||||
'xai',
|
||||
'speechify',
|
||||
'kling',
|
||||
] as const;
|
||||
|
||||
export type TTSProviderName = (typeof TTS_PROVIDERS)[number];
|
||||
@@ -50,6 +51,7 @@ export const TTS_DRIVER_ALIASES = [
|
||||
'gemini-tts',
|
||||
'xai-tts',
|
||||
'speechify-tts',
|
||||
'kling-tts',
|
||||
] as const;
|
||||
|
||||
const PROVIDER_BY_ALIAS: Record<string, TTSProviderName> = {
|
||||
@@ -76,6 +78,9 @@ const PROVIDER_BY_ALIAS: Record<string, TTSProviderName> = {
|
||||
simba: 'speechify',
|
||||
speechify: 'speechify',
|
||||
'speechify-tts': 'speechify',
|
||||
kling: 'kling',
|
||||
'kling-ai': 'kling',
|
||||
'kling-tts': 'kling',
|
||||
};
|
||||
|
||||
export const TTS_CATALOG: ProviderCatalog = {
|
||||
|
||||
@@ -101,8 +101,7 @@ const aiMetering = () =>
|
||||
|
||||
const makeProvider = (region?: string) =>
|
||||
new AWSPollyTTSProvider(aiMetering(), {
|
||||
access_key: 'AKIA-TEST',
|
||||
secret_key: 'secret',
|
||||
credentials: { accessKeyId: 'AKIA-TEST', secretAccessKey: 'secret' },
|
||||
region,
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '@aws-sdk/client-polly';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { AwsCredentials } from '../../../../util/awsCredentials.js';
|
||||
import type { AiMeteringService } from '../../../util/aiCostFactor.js';
|
||||
import type { DriverStreamResult } from '../../../meta.js';
|
||||
import { SAMPLE_AUDIO_URL } from '../../../util/testMode.js';
|
||||
@@ -83,8 +84,7 @@ export class AWSPollyTTSProvider implements ITTSProvider {
|
||||
constructor(
|
||||
private readonly meteringService: AiMeteringService,
|
||||
private readonly config: {
|
||||
access_key: string;
|
||||
secret_key: string;
|
||||
credentials: AwsCredentials;
|
||||
region?: string;
|
||||
},
|
||||
) {
|
||||
@@ -115,10 +115,7 @@ export class AWSPollyTTSProvider implements ITTSProvider {
|
||||
}
|
||||
|
||||
this.clients[resolvedRegion] = new PollyClient({
|
||||
credentials: {
|
||||
accessKeyId: cfg.access_key,
|
||||
secretAccessKey: cfg.secret_key,
|
||||
},
|
||||
credentials: cfg.credentials,
|
||||
region: resolvedRegion,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Integration test for the Kling TTS provider ($0.007/request). Skipped when
|
||||
* `PUTER_TEST_AI_KLING_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
readStreamResult,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { KlingTTSProvider } from './KlingTTSProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_KLING_API_KEY';
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'KlingTTSProvider (integration)',
|
||||
() => {
|
||||
it(
|
||||
'returns MP3 audio from kling-tts',
|
||||
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const provider = new KlingTTSProvider(makeMeteringStub(), {
|
||||
apiKey: optionalEnv(ENV_VAR)!,
|
||||
});
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.synthesize({ text: 'hi', model: 'kling-tts' }),
|
||||
);
|
||||
const audio = await readStreamResult(result);
|
||||
|
||||
expect(audio.length).toBeGreaterThan(0);
|
||||
expect(audio.subarray(0, 3).toString()).toBe('ID3');
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,359 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Offline unit tests for KlingTTSProvider.
|
||||
*
|
||||
* Boots a real PuterServer and constructs the provider against the live wired
|
||||
* `MeteringService`. Kling has no SDK, so global `fetch` is the network
|
||||
* boundary: the first call creates the TTS task, the second downloads the MP3
|
||||
* it points at.
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withAiCostFactor } from '../../../util/aiCostFactor.js';
|
||||
import {
|
||||
readStreamResult,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { SAMPLE_AUDIO_URL } from '../../../util/testMode.js';
|
||||
import {
|
||||
KLING_TTS_UCENTS_PER_REQUEST,
|
||||
KlingTTSProvider,
|
||||
} from './KlingTTSProvider.js';
|
||||
|
||||
// -- Test harness --
|
||||
|
||||
const AUDIO_URL = 'https://cdn.kling.example/audio.mp3';
|
||||
|
||||
let server: PuterServer;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const aiMetering = () =>
|
||||
withAiCostFactor(server.services.metering, server.clients.event, 'ai-tts');
|
||||
|
||||
const makeProvider = () =>
|
||||
new KlingTTSProvider(aiMetering(), { apiKey: 'test-key' });
|
||||
|
||||
const klingTask = (overrides: Record<string, unknown> = {}) =>
|
||||
Response.json({
|
||||
code: 0,
|
||||
message: 'SUCCEED',
|
||||
data: {
|
||||
task_id: 't1',
|
||||
task_status: 'succeed',
|
||||
task_result: { audios: [{ id: 'a1', url: AUDIO_URL }] },
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
const mp3 = (body = 'ID3-mp3-bytes') =>
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'audio/mpeg' },
|
||||
});
|
||||
|
||||
const sentBody = () =>
|
||||
JSON.parse((fetchSpy.mock.calls[0]![1] as RequestInit).body as string);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// -- Construction and catalog --
|
||||
|
||||
describe('KlingTTSProvider construction and catalog', () => {
|
||||
it('throws when no apiKey is supplied', () => {
|
||||
expect(
|
||||
() => new KlingTTSProvider(aiMetering(), { apiKey: '' }),
|
||||
).toThrow(/API key/i);
|
||||
});
|
||||
|
||||
it('lists the 44 voices Kling accepts, labelled with their languages', async () => {
|
||||
const voices = await makeProvider().listVoices();
|
||||
expect(voices).toHaveLength(44);
|
||||
for (const voice of voices) {
|
||||
expect(voice.provider).toBe('kling');
|
||||
expect(voice.supported_models).toEqual(['kling-tts']);
|
||||
expect(voice.labels!.languages.split(',')[0]).toBe(
|
||||
voice.language!.code,
|
||||
);
|
||||
}
|
||||
expect(voices.find((v) => v.id === 'oversea_male1')).toMatchObject({
|
||||
language: { code: 'en', name: 'English' },
|
||||
labels: { languages: 'en' },
|
||||
});
|
||||
expect(voices.find((v) => v.id === 'genshin_vindi2')).toMatchObject({
|
||||
labels: { languages: 'en,zh' },
|
||||
});
|
||||
expect(voices.find((v) => v.id === 'mengwa-v1')).toMatchObject({
|
||||
language: { code: 'zh', name: 'Chinese' },
|
||||
});
|
||||
});
|
||||
|
||||
it('lists one kling-tts engine', async () => {
|
||||
expect(await makeProvider().listEngines()).toEqual([
|
||||
{ id: 'kling-tts', name: 'Kling TTS', provider: 'kling' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a per-request cost', () => {
|
||||
expect(makeProvider().getReportedCosts()).toEqual([
|
||||
{
|
||||
usageType: 'kling:kling-tts:request',
|
||||
ucentsPerUnit: 700_000,
|
||||
unit: 'request',
|
||||
source: 'driver:aiTts/kling',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// -- test_mode --
|
||||
|
||||
describe('KlingTTSProvider.synthesize test_mode', () => {
|
||||
it('returns the sample audio without calling Kling', async () => {
|
||||
const result = await withTestActor(() =>
|
||||
makeProvider().synthesize({ text: 'hi', test_mode: true }),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
url: SAMPLE_AUDIO_URL,
|
||||
content_type: 'audio',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -- Request shape --
|
||||
|
||||
describe('KlingTTSProvider.synthesize request shape', () => {
|
||||
it('POSTs /v1/audio/tts with Bearer auth and the default voice', async () => {
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(klingTask())
|
||||
.mockResolvedValueOnce(mp3());
|
||||
|
||||
await readStreamResult(
|
||||
await withTestActor(() =>
|
||||
makeProvider().synthesize({ text: 'hello' }),
|
||||
),
|
||||
);
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(String(url)).toBe(
|
||||
'https://api-singapore.klingai.com/v1/audio/tts',
|
||||
);
|
||||
expect((init as RequestInit).method).toBe('POST');
|
||||
expect(
|
||||
((init as RequestInit).headers as Record<string, string>)
|
||||
.Authorization,
|
||||
).toBe('Bearer test-key');
|
||||
expect(sentBody()).toEqual({
|
||||
text: 'hello',
|
||||
voice_id: 'oversea_male1',
|
||||
voice_language: 'en',
|
||||
});
|
||||
expect(String(fetchSpy.mock.calls[1]![0])).toBe(AUDIO_URL);
|
||||
});
|
||||
|
||||
it('reads a regional language code as its base language', async () => {
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(klingTask())
|
||||
.mockResolvedValueOnce(mp3());
|
||||
|
||||
await withTestActor(() =>
|
||||
makeProvider().synthesize({
|
||||
text: 'hi',
|
||||
voice: 'genshin_vindi2',
|
||||
language: 'en-US',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sentBody()).toMatchObject({
|
||||
voice_id: 'genshin_vindi2',
|
||||
voice_language: 'en',
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults a Chinese-only voice to 'zh'", async () => {
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(klingTask())
|
||||
.mockResolvedValueOnce(mp3());
|
||||
|
||||
await withTestActor(() =>
|
||||
makeProvider().synthesize({ text: 'hi', voice: 'mengwa-v1' }),
|
||||
);
|
||||
|
||||
expect(sentBody().voice_language).toBe('zh');
|
||||
});
|
||||
|
||||
it('sends voice_settings.speed as voice_speed', async () => {
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(klingTask())
|
||||
.mockResolvedValueOnce(mp3());
|
||||
|
||||
await withTestActor(() =>
|
||||
makeProvider().synthesize({
|
||||
text: 'hi',
|
||||
voice_settings: { speed: 1.5 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sentBody().voice_speed).toBe(1.5);
|
||||
});
|
||||
|
||||
it('streams the MP3 back as audio/mpeg', async () => {
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(klingTask())
|
||||
.mockResolvedValueOnce(mp3('AAA-BBB'));
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
makeProvider().synthesize({ text: 'hi' }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
dataType: 'stream',
|
||||
content_type: 'audio/mpeg',
|
||||
chunked: true,
|
||||
});
|
||||
expect((await readStreamResult(result)).toString()).toBe('AAA-BBB');
|
||||
});
|
||||
});
|
||||
|
||||
// -- Metering --
|
||||
|
||||
describe('KlingTTSProvider.synthesize metering', () => {
|
||||
it('meters one request once the audio arrives', async () => {
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(klingTask())
|
||||
.mockResolvedValueOnce(mp3());
|
||||
|
||||
await readStreamResult(
|
||||
await withTestActor(() =>
|
||||
makeProvider().synthesize({ text: 'hi' }),
|
||||
),
|
||||
);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(incrementUsageSpy).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(usageType).toBe('kling:kling-tts:request');
|
||||
expect(count).toBe(1);
|
||||
expect(cost).toBe(KLING_TTS_UCENTS_PER_REQUEST);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a Kling error code',
|
||||
() =>
|
||||
Response.json(
|
||||
{ code: 1201, message: 'Voice id not found' },
|
||||
{ status: 400 },
|
||||
),
|
||||
],
|
||||
[
|
||||
'a failed task',
|
||||
() =>
|
||||
klingTask({
|
||||
task_status: 'failed',
|
||||
task_status_msg: 'failed',
|
||||
task_result: {},
|
||||
}),
|
||||
],
|
||||
['a task with no audio URL', () => klingTask({ task_result: {} })],
|
||||
])('meters nothing for %s', async (_label, reply) => {
|
||||
fetchSpy.mockResolvedValueOnce(reply());
|
||||
|
||||
await expect(
|
||||
withTestActor(() => makeProvider().synthesize({ text: 'hi' })),
|
||||
).rejects.toBeTruthy();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -- Validation --
|
||||
|
||||
describe('KlingTTSProvider.synthesize validation', () => {
|
||||
it.each([
|
||||
['empty text', { text: '' }, undefined],
|
||||
[
|
||||
'text over 1000 characters',
|
||||
{ text: 'x'.repeat(1001) },
|
||||
'input_too_large',
|
||||
],
|
||||
['an unknown voice', { text: 'hi', voice: 'nope' }, 'field_invalid'],
|
||||
['an unknown model', { text: 'hi', model: 'tts-1' }, 'field_invalid'],
|
||||
[
|
||||
'a language the voice lacks',
|
||||
{ text: 'hi', voice: 'oversea_male1', language: 'zh' },
|
||||
'field_invalid',
|
||||
],
|
||||
[
|
||||
'a speed below 0.8',
|
||||
{ text: 'hi', voice_settings: { speed: 0.5 } },
|
||||
'field_invalid',
|
||||
],
|
||||
[
|
||||
'a speed above 2',
|
||||
{ text: 'hi', voice_settings: { speed: 2.5 } },
|
||||
'field_invalid',
|
||||
],
|
||||
])(
|
||||
'rejects %s with 400 before calling Kling',
|
||||
async (_label, args, legacyCode) => {
|
||||
await expect(
|
||||
withTestActor(() => makeProvider().synthesize(args)),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
...(legacyCode ? { legacyCode } : {}),
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* 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 { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import type { DriverStreamResult } from '../../../meta.js';
|
||||
import {
|
||||
type AiMeteringService,
|
||||
withStreamedAiCharge,
|
||||
} from '../../../util/aiCostFactor.js';
|
||||
import { klingRequest } from '../../../util/kling.js';
|
||||
import { SAMPLE_AUDIO_URL } from '../../../util/testMode.js';
|
||||
import {
|
||||
upstreamBodyStream,
|
||||
upstreamFetch,
|
||||
} from '../../../util/upstreamErrors.js';
|
||||
import type {
|
||||
ISynthesizeArgs,
|
||||
ITTSEngine,
|
||||
ITTSProvider,
|
||||
ITTSVoice,
|
||||
} from '../../types.js';
|
||||
import { assertMaxChars, TTS_UPSTREAM_TIMEOUT_MS } from '../common.js';
|
||||
|
||||
const MODEL = 'kling-tts';
|
||||
const DEFAULT_VOICE = 'oversea_male1';
|
||||
const MAX_TEXT_CHARS = 1000;
|
||||
|
||||
/** $0.007 per request, https://kling.ai/document-api/pricing/base/video. */
|
||||
export const KLING_TTS_UCENTS_PER_REQUEST = 700_000;
|
||||
|
||||
const LANGUAGE_NAMES: Record<string, string> = {
|
||||
en: 'English',
|
||||
zh: 'Chinese',
|
||||
};
|
||||
|
||||
// Voice ids Kling accepted on 2026-10-20, with the languages each answered
|
||||
// to; Kling's voice guide is not published as data. Requesting a voice in a
|
||||
// language it lacks fails upstream with "Voice id not found".
|
||||
const VOICES: Record<string, readonly string[]> = {
|
||||
genshin_vindi2: ['en', 'zh'],
|
||||
zhinen_xuesheng: ['en', 'zh'],
|
||||
AOT: ['en'],
|
||||
ai_shatang: ['en', 'zh'],
|
||||
genshin_klee2: ['en', 'zh'],
|
||||
genshin_kirara: ['en', 'zh'],
|
||||
ai_kaiya: ['en', 'zh'],
|
||||
oversea_male1: ['en'],
|
||||
ai_chenjiahao_712: ['en', 'zh'],
|
||||
girlfriend_4_speech02: ['en'],
|
||||
'chat1_female_new-3': ['en', 'zh'],
|
||||
'chat_0407_5-1': ['en'],
|
||||
'cartoon-boy-07': ['en', 'zh'],
|
||||
uk_boy1: ['en'],
|
||||
'cartoon-girl-01': ['en', 'zh'],
|
||||
PeppaPig_platform: ['en'],
|
||||
ai_huangzhong_712: ['en'],
|
||||
ai_huangyaoshi_712: ['en', 'zh'],
|
||||
ai_laoguowang_712: ['en', 'zh'],
|
||||
chengshu_jiejie: ['en', 'zh'],
|
||||
you_pingjing: ['en', 'zh'],
|
||||
calm_story1: ['en'],
|
||||
uk_man2: ['en'],
|
||||
laopopo_speech02: ['en', 'zh'],
|
||||
heainainai_speech02: ['en', 'zh'],
|
||||
tiyuxi_xuedi: ['zh'],
|
||||
tiexin_nanyou: ['zh'],
|
||||
girlfriend_1_speech02: ['zh'],
|
||||
girlfriend_2_speech02: ['zh'],
|
||||
zhuxi_speech02: ['zh'],
|
||||
uk_oldman3: ['zh'],
|
||||
dongbeilaotie_speech02: ['zh'],
|
||||
chongqingxiaohuo_speech02: ['zh'],
|
||||
chuanmeizi_speech02: ['zh'],
|
||||
chaoshandashu_speech02: ['zh'],
|
||||
ai_taiwan_man2_speech02: ['zh'],
|
||||
xianzhanggui_speech02: ['zh'],
|
||||
tianjinjiejie_speech02: ['zh'],
|
||||
'diyinnansang_DB_CN_M_04-v2': ['zh'],
|
||||
'yizhipiannan-v1': ['zh'],
|
||||
'guanxiaofang-v2': ['zh'],
|
||||
'tianmeixuemei-v1': ['zh'],
|
||||
'daopianyansang-v1': ['zh'],
|
||||
'mengwa-v1': ['zh'],
|
||||
};
|
||||
|
||||
interface KlingTTSTask {
|
||||
task_status?: string;
|
||||
task_status_msg?: string;
|
||||
task_result?: { audios?: Array<{ url?: string }> };
|
||||
}
|
||||
|
||||
const fieldInvalid = (key: string, message: string) =>
|
||||
new HttpError(400, message, {
|
||||
legacyCode: 'field_invalid',
|
||||
fields: { key },
|
||||
});
|
||||
|
||||
/**
|
||||
* Kling AI text-to-speech. `/v1/audio/tts` answers synchronously with a hosted
|
||||
* MP3, which is streamed back to the caller. Billed per request.
|
||||
* https://kling.ai/document-api/api/video/avatar/text-to-speech
|
||||
*/
|
||||
export class KlingTTSProvider implements ITTSProvider {
|
||||
readonly providerName = 'kling';
|
||||
|
||||
#apiKey: string;
|
||||
#apiBaseUrl: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly meteringService: AiMeteringService,
|
||||
config: { apiKey: string; apiBaseUrl?: string },
|
||||
) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error('Kling TTS requires an API key');
|
||||
}
|
||||
this.#apiKey = config.apiKey;
|
||||
this.#apiBaseUrl = config.apiBaseUrl;
|
||||
}
|
||||
|
||||
async listVoices(): Promise<ITTSVoice[]> {
|
||||
return Object.entries(VOICES).map(([id, languages]) => ({
|
||||
id,
|
||||
name: id,
|
||||
language: {
|
||||
name: LANGUAGE_NAMES[languages[0]],
|
||||
code: languages[0],
|
||||
},
|
||||
labels: { languages: languages.join(',') },
|
||||
provider: 'kling',
|
||||
supported_models: [MODEL],
|
||||
}));
|
||||
}
|
||||
|
||||
async listEngines(): Promise<ITTSEngine[]> {
|
||||
return [{ id: MODEL, name: 'Kling TTS', provider: 'kling' }];
|
||||
}
|
||||
|
||||
getReportedCosts(): Record<string, unknown>[] {
|
||||
return [
|
||||
{
|
||||
usageType: `kling:${MODEL}:request`,
|
||||
ucentsPerUnit: KLING_TTS_UCENTS_PER_REQUEST,
|
||||
unit: 'request',
|
||||
source: 'driver:aiTts/kling',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async synthesize(
|
||||
args: ISynthesizeArgs,
|
||||
): Promise<DriverStreamResult | { url: string; content_type: string }> {
|
||||
const { text, voice, model, language, test_mode } = args;
|
||||
const speed = (args.voice_settings ?? args.voiceSettings)?.speed;
|
||||
|
||||
if (test_mode) {
|
||||
return { url: SAMPLE_AUDIO_URL, content_type: 'audio' };
|
||||
}
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw new HttpError(400, 'Missing required field: text', {
|
||||
legacyCode: 'field_required',
|
||||
fields: { key: 'text' },
|
||||
});
|
||||
}
|
||||
assertMaxChars('text', text, MAX_TEXT_CHARS);
|
||||
if (model !== undefined && model !== MODEL) {
|
||||
throw fieldInvalid(
|
||||
'model',
|
||||
`Invalid model: ${model}. Expected: ${MODEL}`,
|
||||
);
|
||||
}
|
||||
|
||||
const voiceId = voice || DEFAULT_VOICE;
|
||||
const languages = VOICES[voiceId];
|
||||
if (!languages) {
|
||||
throw fieldInvalid('voice', `Invalid Kling voice: ${voiceId}`);
|
||||
}
|
||||
// `en-US` and `zh-CN` name the same languages as `en` and `zh`.
|
||||
const wanted =
|
||||
typeof language === 'string'
|
||||
? language.trim().slice(0, 2).toLowerCase()
|
||||
: undefined;
|
||||
if (wanted && !languages.includes(wanted)) {
|
||||
throw fieldInvalid(
|
||||
'language',
|
||||
`Kling voice ${voiceId} speaks ${languages.join(' or ')}, not ${language}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
speed !== undefined &&
|
||||
(typeof speed !== 'number' || speed < 0.8 || speed > 2)
|
||||
) {
|
||||
throw fieldInvalid(
|
||||
'voice_settings.speed',
|
||||
'voice_settings.speed must be a number from 0.8 to 2.0',
|
||||
);
|
||||
}
|
||||
|
||||
const actor = Context.get('actor')!;
|
||||
const usageType = `kling:${MODEL}:request`;
|
||||
return withStreamedAiCharge(
|
||||
this.meteringService,
|
||||
actor,
|
||||
usageType,
|
||||
KLING_TTS_UCENTS_PER_REQUEST,
|
||||
async () => {
|
||||
const task = await klingRequest<KlingTTSTask>(
|
||||
this.#apiKey,
|
||||
'POST',
|
||||
'/v1/audio/tts',
|
||||
{
|
||||
baseUrl: this.#apiBaseUrl,
|
||||
timeoutMs: TTS_UPSTREAM_TIMEOUT_MS,
|
||||
body: {
|
||||
text,
|
||||
voice_id: voiceId,
|
||||
voice_language: wanted ?? languages[0],
|
||||
...(speed !== undefined
|
||||
? { voice_speed: speed }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
const url = task.task_result?.audios?.[0]?.url;
|
||||
if (task.task_status !== 'succeed' || !url) {
|
||||
throw new HttpError(
|
||||
502,
|
||||
task.task_status_msg || 'Kling TTS returned no audio',
|
||||
{
|
||||
legacyCode: 'upstream_failed',
|
||||
fields: { provider: 'kling' },
|
||||
},
|
||||
);
|
||||
}
|
||||
const response = await upstreamFetch(
|
||||
'kling',
|
||||
url,
|
||||
{ method: 'GET' },
|
||||
{
|
||||
providerLabel: 'Kling TTS',
|
||||
timeoutMs: TTS_UPSTREAM_TIMEOUT_MS,
|
||||
stream: true,
|
||||
},
|
||||
);
|
||||
return {
|
||||
dataType: 'stream',
|
||||
content_type: 'audio/mpeg',
|
||||
chunked: true,
|
||||
stream: upstreamBodyStream(response),
|
||||
};
|
||||
},
|
||||
() =>
|
||||
this.meteringService.incrementUsage(
|
||||
actor,
|
||||
usageType,
|
||||
1,
|
||||
KLING_TTS_UCENTS_PER_REQUEST,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,11 +40,14 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { SYSTEM_ACTOR } from '../../core/actor.js';
|
||||
import { type Actor, SYSTEM_ACTOR } from '../../core/actor.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import type { MeteringService } from '../../services/metering/MeteringService.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { withAiCostFactorHook } from '../integrationTestUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { TOGETHER_VIDEO_GENERATION_MODELS } from './providers/together/models.js';
|
||||
import type { VideoGenerationDriver } from './VideoGenerationDriver.js';
|
||||
|
||||
@@ -99,6 +102,12 @@ beforeAll(async () => {
|
||||
// Configured on purpose: Gemini video reads the shared `gemini`
|
||||
// block, so this one alone must not register a provider.
|
||||
'gemini-video-generation': { apiKey: 'gem-key' },
|
||||
kling: { apiKey: 'kling-key' },
|
||||
// The chat block's base URL ends in `/v1`; video must drop it.
|
||||
minimax: {
|
||||
apiKey: 'mm-key',
|
||||
apiBaseUrl: 'https://api.minimax.io/v1',
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
driver = server.drivers.aiVideo as unknown as VideoGenerationDriver;
|
||||
@@ -165,6 +174,161 @@ describe('VideoGenerationDriver.generate argument validation', () => {
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('refuses a source video for a model without video input before calling it', async () => {
|
||||
await expect(
|
||||
withActor(() =>
|
||||
driver.generate({
|
||||
prompt: 'Make this dog a dinosaur.',
|
||||
model: DEFAULT_MODEL,
|
||||
input_video: 'https://example.com/dog.mp4',
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: `${DEFAULT_MODEL} does not accept a source video`,
|
||||
});
|
||||
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
|
||||
expect(secureFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoGenerationDriver.generate media inputs', () => {
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const pngDataUri = `data:image/png;base64,${PNG.toString('base64')}`;
|
||||
|
||||
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
|
||||
const username = `video-media-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
created,
|
||||
);
|
||||
const user = (await server.stores.user.getById(created.id))!;
|
||||
return {
|
||||
userId: user.id,
|
||||
actor: {
|
||||
user: {
|
||||
id: user.id,
|
||||
uuid: user.uuid,
|
||||
username: user.username,
|
||||
email: user.email ?? null,
|
||||
email_confirmed: true,
|
||||
} as Actor['user'],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const writeImage = async (
|
||||
{ actor, userId }: { actor: Actor; userId: number },
|
||||
name: string,
|
||||
) => {
|
||||
const path = `/${actor.user.username}/${name}`;
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: PNG.byteLength,
|
||||
contentType: 'application/octet-stream',
|
||||
},
|
||||
fileContent: PNG,
|
||||
});
|
||||
return path;
|
||||
};
|
||||
|
||||
const generateAs = (actor: Actor, args: Record<string, unknown>) =>
|
||||
Promise.resolve(
|
||||
runWithContext({ actor }, () =>
|
||||
driver.generate({
|
||||
prompt: 'A dinosaur.',
|
||||
model: DEFAULT_MODEL,
|
||||
...args,
|
||||
} as never),
|
||||
),
|
||||
);
|
||||
|
||||
it("sends Puter file image inputs as data URIs without changing the caller's arrays", async () => {
|
||||
const user = await makeUser();
|
||||
const first = await writeImage(user, 'first.tmp');
|
||||
const reference = await writeImage(user, 'reference.tmp');
|
||||
const referenceImages = [reference, 'https://example.com/b.png'];
|
||||
const frameImages = [{ input_image: first, frame: 'first' }];
|
||||
|
||||
completeTogetherJob();
|
||||
await generateAs(user.actor, {
|
||||
reference_images: referenceImages,
|
||||
frame_images: frameImages,
|
||||
});
|
||||
expect(togetherSent().reference_images).toEqual([
|
||||
pngDataUri,
|
||||
'https://example.com/b.png',
|
||||
]);
|
||||
expect(togetherSent().frame_images).toEqual([
|
||||
{ input_image: pngDataUri, frame: 'first' },
|
||||
]);
|
||||
expect(referenceImages[0]).toBe(reference);
|
||||
expect(frameImages[0]!.input_image).toBe(first);
|
||||
|
||||
completeTogetherJob();
|
||||
await generateAs(user.actor, {
|
||||
input_reference: first,
|
||||
last_frame: 'https://example.com/last.png',
|
||||
});
|
||||
expect(togetherSent(1).frame_images).toEqual([
|
||||
{ input_image: pngDataUri, frame: 'first' },
|
||||
{ input_image: 'https://example.com/last.png', frame: 'last' },
|
||||
]);
|
||||
expect(secureFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses another user's image before calling the provider", async () => {
|
||||
const owner = await makeUser();
|
||||
const intruder = await makeUser();
|
||||
const path = await writeImage(owner, 'private.png');
|
||||
const status = await generateAs(intruder.actor, {
|
||||
input_reference: path,
|
||||
}).then(
|
||||
() => null,
|
||||
(err: { statusCode?: number }) => err.statusCode,
|
||||
);
|
||||
expect([403, 404]).toContain(status);
|
||||
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads no file in test mode', async () => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
generateAs(actor, {
|
||||
input_reference: '/nobody/missing.png',
|
||||
test_mode: true,
|
||||
}),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('refuses a data URI source video over 1 MiB with 413', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const video = Buffer.alloc(1024 * 1024 + 1).toString('base64');
|
||||
await expect(
|
||||
generateAs(actor, {
|
||||
model: 'togetherai:black-forest-labs/flux-3',
|
||||
input_video: `data:video/mp4;base64,${video}`,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 413,
|
||||
legacyCode: 'input_too_large',
|
||||
});
|
||||
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Catalog & list ──────────────────────────────────────────────────
|
||||
@@ -289,6 +453,113 @@ describe('VideoGenerationDriver.generate provider routing', () => {
|
||||
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['kling-3.0', 'kling-v3', 'kling:kling/kling-3.0'])(
|
||||
'routes %s to the Kling provider from the shared kling key',
|
||||
async (model) => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: { id: 'kl-task', status: 'submitted' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: [
|
||||
{
|
||||
id: 'kl-task',
|
||||
status: 'succeeded',
|
||||
outputs: [
|
||||
{
|
||||
type: 'video',
|
||||
url: 'https://kling/out.mp4',
|
||||
duration: '5',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await withActor(() =>
|
||||
driver.generate({ prompt: 'hi', model } as never),
|
||||
);
|
||||
|
||||
expect(result).toBe('https://kling/out.mp4');
|
||||
expect(String(fetchSpy.mock.calls[0]![0])).toBe(
|
||||
'https://api-singapore.klingai.com/text-to-video/kling-3.0',
|
||||
);
|
||||
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('lists the Kling models under the kling-video-generation provider', async () => {
|
||||
const kling = (await driver.models()).filter(
|
||||
(m) => m.provider === 'kling-video-generation',
|
||||
);
|
||||
expect(kling.map((m) => m.id).sort()).toEqual([
|
||||
'kling-2.5-turbo',
|
||||
'kling-2.6',
|
||||
'kling-3.0',
|
||||
'kling-3.0-omni',
|
||||
'kling-3.0-turbo',
|
||||
'kling-o1',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'minimax-h3',
|
||||
'minimax/minimax-h3',
|
||||
'minimax:minimax/minimax-h3',
|
||||
])('routes %s to the MiniMax provider from the shared minimax key', async (model) => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ task_id: 'mm-task' })),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
task: {
|
||||
id: 'mm-task',
|
||||
status: 'succeeded',
|
||||
content: { url: 'https://minimax/out.mp4' },
|
||||
usage: { output_seconds: 5 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await withActor(() =>
|
||||
driver.generate({ prompt: 'hi', model } as never),
|
||||
);
|
||||
|
||||
expect(result).toBe('https://minimax/out.mp4');
|
||||
expect(String(fetchSpy.mock.calls[0]![0])).toBe(
|
||||
'https://api.minimax.io/v2/video_generation',
|
||||
);
|
||||
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists the MiniMax models under the minimax-video-generation provider', async () => {
|
||||
const minimax = (await driver.models()).filter(
|
||||
(m) => m.provider === 'minimax-video-generation',
|
||||
);
|
||||
expect(minimax.map((m) => m.id).sort()).toEqual([
|
||||
'minimax-h3',
|
||||
'minimax-h3-highspeed',
|
||||
'minimax-h3-lite',
|
||||
'minimax-h3-max',
|
||||
'minimax-hailuo-02',
|
||||
'minimax-hailuo-2.3',
|
||||
'minimax-hailuo-2.3-fast',
|
||||
]);
|
||||
});
|
||||
|
||||
it('routes a known togetherai:minimax/video-01-director id to the Together provider', async () => {
|
||||
togetherVideosCreateMock.mockResolvedValueOnce({ id: 'tg-job' });
|
||||
togetherVideosRetrieveMock.mockResolvedValueOnce({
|
||||
@@ -730,6 +1001,49 @@ describe('VideoGenerationDriver.generate puter_output_path', () => {
|
||||
// ── Size unification ───────────────────────────────────────────────
|
||||
|
||||
describe('VideoGenerationDriver.generate size unification', () => {
|
||||
it.each(['912x512', '512x912'])(
|
||||
'selects and bills Hailuo 02 at 512p for %s',
|
||||
async (size) => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
const incrementUsageSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'incrementUsage',
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ task_id: 'mm-task' })),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
status: 'Success',
|
||||
file_id: 'file-1',
|
||||
})),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
file: { download_url: 'https://minimax/out.mp4' },
|
||||
})),
|
||||
);
|
||||
|
||||
await withActor(() => driver.generate({
|
||||
prompt: 'a ball rolls',
|
||||
model: 'minimax-hailuo-02',
|
||||
size,
|
||||
input_reference: 'https://example.com/first.png',
|
||||
} as never));
|
||||
|
||||
const body = JSON.parse(
|
||||
fetchSpy.mock.calls[0]![1]!.body as string,
|
||||
);
|
||||
expect(body.resolution).toBe('512P');
|
||||
expect(incrementUsageSpy).toHaveBeenCalledWith(
|
||||
SYSTEM_ACTOR,
|
||||
'minimax-video-generation:minimax-hailuo-02:per-video:512p:6',
|
||||
1,
|
||||
10 * 1_000_000,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('maps a WIDTHxHEIGHT size onto a tier plus aspect ratio for tier-based models', async () => {
|
||||
completeTogetherJob();
|
||||
|
||||
@@ -763,6 +1077,77 @@ describe('VideoGenerationDriver.generate size unification', () => {
|
||||
expect('ratio' in sent).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a catalog with a 2k or 4k tier as tier-based', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: { id: 'kl-task', status: 'submitted' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: [
|
||||
{
|
||||
id: 'kl-task',
|
||||
status: 'succeeded',
|
||||
outputs: [
|
||||
{
|
||||
type: 'video',
|
||||
url: 'https://kling/out.mp4',
|
||||
duration: '5',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
prompt: 'hi',
|
||||
model: 'kling-3.0',
|
||||
size: '1080x1920',
|
||||
} as never),
|
||||
);
|
||||
const sent = JSON.parse(
|
||||
(fetchSpy.mock.calls[0]![1] as RequestInit).body as string,
|
||||
);
|
||||
expect(sent.settings.resolution).toBe('1080p');
|
||||
expect(sent.settings.aspect_ratio).toBe('9:16');
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ task_id: 'mm-task' })),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
task: {
|
||||
id: 'mm-task',
|
||||
status: 'succeeded',
|
||||
content: { url: 'https://minimax/out.mp4' },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
prompt: 'hi',
|
||||
model: 'minimax-h3-lite',
|
||||
size: '854x480',
|
||||
} as never),
|
||||
);
|
||||
const sentToMiniMax = JSON.parse(
|
||||
(fetchSpy.mock.calls[2]![1] as RequestInit).body as string,
|
||||
);
|
||||
expect(sentToMiniMax.resolution).toBe('480P');
|
||||
expect(sentToMiniMax.ratio).toBe('16:9');
|
||||
});
|
||||
|
||||
it('fills width/height from a WIDTHxHEIGHT size for pixel-sized models', async () => {
|
||||
completeTogetherJob();
|
||||
|
||||
@@ -808,3 +1193,31 @@ describe('VideoGenerationDriver.generate size unification', () => {
|
||||
expect('height' in second).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoGenerationDriver cost factor', () => {
|
||||
it('holds and meters at the AI cost factor', async () => {
|
||||
const incrementUsageSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'incrementUsage',
|
||||
);
|
||||
const reserveSpy = vi.spyOn(server.services.metering, 'reserveCredits');
|
||||
const metered = () =>
|
||||
incrementUsageSpy.mock.calls.map(([, , , cost]) => cost!);
|
||||
const generate = async (charges: number) => {
|
||||
completeTogetherJob();
|
||||
await withActor(() =>
|
||||
driver.generate({ prompt: 'hi', model: DEFAULT_MODEL } as never),
|
||||
);
|
||||
await vi.waitFor(() => expect(metered()).toHaveLength(charges));
|
||||
};
|
||||
await generate(1);
|
||||
await withAiCostFactorHook(server.clients.event, 2, () => generate(2));
|
||||
|
||||
const [base, factored] = metered();
|
||||
expect(base).toBeGreaterThan(0);
|
||||
expect(factored).toBe(Math.round(base! * 2));
|
||||
const held = reserveSpy.mock.calls.map(([, amount]) => amount);
|
||||
expect(held[0]).toBeGreaterThan(0);
|
||||
expect(held).toEqual([held[0], Math.round(held[0]! * 2)]);
|
||||
});
|
||||
});
|
||||
@@ -31,10 +31,17 @@ import {
|
||||
saveGeneratedMediaToFS,
|
||||
} from '../util/generatedMedia.js';
|
||||
import { usesGoogleGeminiEndpoint } from '../util/geminiRest.js';
|
||||
import {
|
||||
dataUriByteLength,
|
||||
MAX_INLINE_VIDEO_BYTES,
|
||||
readPuterImageInputs,
|
||||
} from '../util/mediaInput.js';
|
||||
import { ModelCatalog } from '../util/modelCatalog.js';
|
||||
import { readProviderKey } from '../util/providerRegistry.js';
|
||||
import { BytePlusVideoProvider } from './providers/byteplus/BytePlusVideoProvider.js';
|
||||
import { GeminiVideoProvider } from './providers/gemini/GeminiVideoProvider.js';
|
||||
import { KlingVideoProvider } from './providers/kling/KlingVideoProvider.js';
|
||||
import { MiniMaxVideoProvider } from './providers/minimax/MiniMaxVideoProvider.js';
|
||||
import { TogetherVideoProvider } from './providers/together/TogetherVideoProvider.js';
|
||||
import type {
|
||||
IGenerateVideoParams,
|
||||
@@ -44,7 +51,9 @@ import type {
|
||||
|
||||
const DEFAULT_PROVIDER = 'together-video-generation';
|
||||
|
||||
const isResolutionTier = (value: string): boolean => /^\d{3,4}p$/i.test(value);
|
||||
/** A resolution tier such as `720p`, `2k` or `4k`, as opposed to pixels. */
|
||||
const isResolutionTier = (value: string): boolean =>
|
||||
/^(\d{3,4}p|\dk)$/i.test(value);
|
||||
|
||||
const parsePixelSize = (
|
||||
value: string | undefined,
|
||||
@@ -57,14 +66,15 @@ const parsePixelSize = (
|
||||
};
|
||||
|
||||
/** Resolution tier of the shorter side, e.g. 1920x1080 and 1080x1920 → 1080p. */
|
||||
const tierForPixels = ({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const tierForPixels = (
|
||||
{ width, height }: { width: number; height: number },
|
||||
dimensions: readonly string[],
|
||||
) => {
|
||||
const shortSide = Math.min(width, height);
|
||||
const exactTier = dimensions.find(
|
||||
(tier) => tier.toLowerCase() === `${shortSide}p`,
|
||||
);
|
||||
if (exactTier) return exactTier;
|
||||
if (shortSide <= 480) return '480p';
|
||||
if (shortSide <= 720) return '720p';
|
||||
if (shortSide <= 1080) return '1080p';
|
||||
@@ -74,10 +84,10 @@ const tierForPixels = ({
|
||||
/**
|
||||
* Driver implementing the `puter-video-generation` interface.
|
||||
*
|
||||
* Manages multiple upstream providers (Together, BytePlus/Seedance, Gemini)
|
||||
* and handles model resolution, provider routing, and parameter normalisation. Each
|
||||
* provider is a plain `IVideoProvider` -- the driver instantiates them from
|
||||
* config on boot.
|
||||
* Manages multiple upstream providers (Together, BytePlus/Seedance, Gemini,
|
||||
* Kling, MiniMax) and handles model resolution, provider routing, and parameter
|
||||
* normalisation. Each provider is a plain `IVideoProvider` -- the driver
|
||||
* instantiates them from config on boot.
|
||||
*
|
||||
* Providers handle their own metering internally.
|
||||
*/
|
||||
@@ -91,6 +101,8 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
'together-video-generation',
|
||||
'byteplus-video-generation',
|
||||
'gemini-video-generation',
|
||||
'kling-video-generation',
|
||||
'minimax-video-generation',
|
||||
];
|
||||
readonly isDefault = true;
|
||||
|
||||
@@ -194,9 +206,13 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
|
||||
if (intendedProvider && !this.#providers[intendedProvider]) {
|
||||
if (intendedProvider === 'gemini-video-generation') {
|
||||
throw new HttpError(400, 'Gemini video generation is not configured', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Gemini video generation is not configured',
|
||||
{
|
||||
legacyCode: 'bad_request',
|
||||
},
|
||||
);
|
||||
}
|
||||
intendedProvider = fallbackProvider;
|
||||
}
|
||||
@@ -224,6 +240,29 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
// Providers ignore options they don't know, so a source video sent to
|
||||
// a model that can't edit would be dropped and the clip still billed.
|
||||
if (request.input_video != null && !model.videoInput) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`${model.id} does not accept a source video`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
// A larger video comes as a URL or a Puter file, so it is never
|
||||
// parsed out of the request body or held as one string.
|
||||
if (
|
||||
typeof request.input_video === 'string' &&
|
||||
request.input_video.startsWith('data:') &&
|
||||
dataUriByteLength(request.input_video) > MAX_INLINE_VIDEO_BYTES
|
||||
) {
|
||||
throw new HttpError(
|
||||
413,
|
||||
`A data URI input_video can be at most ${MAX_INLINE_VIDEO_BYTES} bytes; pass a URL or Puter file path instead`,
|
||||
{ legacyCode: 'input_too_large' },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate / normalise duration
|
||||
if (model.durationSeconds?.length) {
|
||||
const requestedSeconds = request.seconds ?? request.duration;
|
||||
@@ -259,7 +298,7 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
const catalogIsTiers = model.dimensions.every(isResolutionTier);
|
||||
const wanted =
|
||||
catalogIsTiers && requestedPixels
|
||||
? tierForPixels(requestedPixels)
|
||||
? tierForPixels(requestedPixels, model.dimensions)
|
||||
: requestedResolution;
|
||||
|
||||
// Case-insensitive so '4K' matches a catalog entry spelled '4k';
|
||||
@@ -289,7 +328,9 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
const result = await provider.generate({
|
||||
...request,
|
||||
...(request.test_mode
|
||||
? request
|
||||
: await this.#withPuterImagesInlined(actor, request)),
|
||||
model: model.id,
|
||||
provider: model.provider,
|
||||
});
|
||||
@@ -307,6 +348,57 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* `request` with image inputs given as Puter file paths read within the
|
||||
* caller's access and replaced by data URIs. `request` is left as is.
|
||||
*/
|
||||
async #withPuterImagesInlined(
|
||||
actor: Actor,
|
||||
request: IGenerateVideoParams,
|
||||
): Promise<IGenerateVideoParams> {
|
||||
const references = Array.isArray(request.reference_images)
|
||||
? request.reference_images
|
||||
: undefined;
|
||||
const frames = Array.isArray(request.frame_images)
|
||||
? request.frame_images
|
||||
: undefined;
|
||||
const frameImage = (frame: object): unknown =>
|
||||
frame && typeof frame === 'object'
|
||||
? (frame as { input_image?: unknown }).input_image
|
||||
: undefined;
|
||||
|
||||
const inlined = await readPuterImageInputs(
|
||||
{ stores: this.stores, fs: this.services.fs },
|
||||
actor,
|
||||
[
|
||||
request.input_reference,
|
||||
request.last_frame,
|
||||
...(references ?? []),
|
||||
...(frames ?? []).map(frameImage),
|
||||
],
|
||||
);
|
||||
if (inlined.size === 0) return request;
|
||||
|
||||
const swap = <T>(value: T): T | string =>
|
||||
(typeof value === 'string' && inlined.get(value)) || value;
|
||||
return {
|
||||
...request,
|
||||
...(request.input_reference != null && {
|
||||
input_reference: swap(request.input_reference),
|
||||
}),
|
||||
...(request.last_frame && { last_frame: swap(request.last_frame) }),
|
||||
...(references && { reference_images: references.map(swap) }),
|
||||
...(frames && {
|
||||
frame_images: frames.map((frame) => {
|
||||
const image = frameImage(frame);
|
||||
return typeof image === 'string' && inlined.has(image)
|
||||
? { ...frame, input_image: inlined.get(image) }
|
||||
: frame;
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// -- Provider registration -----------------------------------------------
|
||||
|
||||
#registerProviders() {
|
||||
@@ -321,7 +413,10 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
);
|
||||
if (togetherKey) {
|
||||
this.#providers['together-video-generation'] =
|
||||
new TogetherVideoProvider({ apiKey: togetherKey }, m);
|
||||
new TogetherVideoProvider({ apiKey: togetherKey }, m, {
|
||||
stores: this.stores,
|
||||
fs: this.services.fs,
|
||||
});
|
||||
}
|
||||
|
||||
// Falls back to the shared `byteplus` (ai-chat) key; `apiBaseUrl`
|
||||
@@ -363,6 +458,41 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
{ stores: this.stores, fs: this.services.fs, metering: m },
|
||||
);
|
||||
}
|
||||
|
||||
// One `kling` key covers video, image and TTS.
|
||||
const klingCfg = (providers['kling-video-generation'] ??
|
||||
providers['kling']) as Record<string, unknown> | undefined;
|
||||
const klingKey = readProviderKey(klingCfg);
|
||||
if (klingKey) {
|
||||
this.#providers['kling-video-generation'] = new KlingVideoProvider(
|
||||
{
|
||||
apiKey: klingKey,
|
||||
apiBaseUrl: klingCfg?.apiBaseUrl as string | undefined,
|
||||
},
|
||||
m,
|
||||
{ stores: this.stores, fs: this.services.fs },
|
||||
);
|
||||
}
|
||||
|
||||
// Falls back to the shared `minimax` (ai-chat) key. The chat block's
|
||||
// `apiBaseUrl` names the chat API under `/v1`; the provider keeps its
|
||||
// host, since the video API lives beside it.
|
||||
const minimaxVideoCfg = providers['minimax-video-generation'] as
|
||||
Record<string, unknown> | undefined;
|
||||
const minimaxSharedCfg = providers['minimax'] as
|
||||
Record<string, unknown> | undefined;
|
||||
const minimaxKey = readProviderKey(minimaxVideoCfg, minimaxSharedCfg);
|
||||
if (minimaxKey) {
|
||||
this.#providers['minimax-video-generation'] =
|
||||
new MiniMaxVideoProvider(
|
||||
{
|
||||
apiKey: minimaxKey,
|
||||
apiBaseUrl: (minimaxVideoCfg?.apiBaseUrl ??
|
||||
minimaxSharedCfg?.apiBaseUrl) as string | undefined,
|
||||
},
|
||||
m,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Model map -----------------------------------------------------------
|
||||
@@ -477,7 +607,9 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
);
|
||||
if (exactPuterIdMatch) return exactPuterIdMatch;
|
||||
|
||||
return models.find((m) => m.provider === provider) ??
|
||||
(provider === 'gemini-video-generation' ? null : models[0]);
|
||||
return (
|
||||
models.find((m) => m.provider === provider) ??
|
||||
(provider === 'gemini-video-generation' ? null : models[0])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
GEMINI_OMNI_COUNT_TOKENS_MAX_RESPONSE_BYTES,
|
||||
GEMINI_OMNI_MAX_IMAGE_BYTES,
|
||||
GEMINI_OMNI_MAX_RESPONSE_BYTES,
|
||||
GEMINI_OMNI_MAX_VIDEO_BYTES,
|
||||
} from './GeminiVideoProvider.js';
|
||||
|
||||
const { secureFetchMock } = vi.hoisted(() => ({ secureFetchMock: vi.fn() }));
|
||||
@@ -240,23 +241,38 @@ const interaction = (
|
||||
...('usage' in opts ? { usage: opts.usage } : { usage: USAGE_720P }),
|
||||
});
|
||||
|
||||
/** Request bodies sent as a stream, read as Gemini would read them. */
|
||||
const streamedBodies = new WeakMap<object, string>();
|
||||
|
||||
/** Answers with `body` once the request body, streamed or not, is read. */
|
||||
const answer =
|
||||
(body: unknown) =>
|
||||
async (_url: unknown, init?: RequestInit): Promise<Response> => {
|
||||
if (init?.body instanceof ReadableStream) {
|
||||
streamedBodies.set(init, await new Response(init.body).text());
|
||||
}
|
||||
return Response.json(body);
|
||||
};
|
||||
|
||||
/** Queues the countTokens reply, then the Interactions reply. */
|
||||
const reply = (
|
||||
body: unknown = interaction(),
|
||||
count: unknown = { totalTokens: COUNTED_TOKENS },
|
||||
) => {
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(Response.json(count))
|
||||
.mockResolvedValueOnce(Response.json(body));
|
||||
.mockImplementationOnce(answer(count))
|
||||
.mockImplementationOnce(answer(body));
|
||||
};
|
||||
|
||||
const sent = (call: number) => {
|
||||
const [url, init] = fetchSpy.mock.calls[call]!;
|
||||
const text = streamedBodies.get(init!) ?? String(init?.body);
|
||||
return {
|
||||
url: String(url),
|
||||
method: init?.method,
|
||||
headers: init?.headers as Record<string, string>,
|
||||
body: JSON.parse(String(init?.body)),
|
||||
text,
|
||||
body: JSON.parse(text),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -879,7 +895,259 @@ describe('GeminiVideoProvider request shape', () => {
|
||||
reply();
|
||||
await withActor(actor, () => generate());
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(30_000);
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(600_000);
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(840_000);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Source video --
|
||||
|
||||
describe('GeminiVideoProvider source video', () => {
|
||||
const mp4DataUri = `data:video/mp4;base64,${MP4.toString('base64')}`;
|
||||
const VIDEO_INPUT_ALLOWANCE = 16000;
|
||||
|
||||
/** Queues only the countTokens reply, for calls that stop before generating. */
|
||||
const countOnly = () =>
|
||||
fetchSpy.mockImplementationOnce(
|
||||
answer({ totalTokens: COUNTED_TOKENS }),
|
||||
);
|
||||
|
||||
const writeVideo = async (
|
||||
{ actor, userId }: { actor: Actor; userId: number },
|
||||
name: string,
|
||||
bytes: Buffer,
|
||||
) => {
|
||||
const path = `/${actor.user.username}/${name}`;
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: bytes.byteLength,
|
||||
contentType: 'application/octet-stream',
|
||||
},
|
||||
fileContent: bytes,
|
||||
});
|
||||
return path;
|
||||
};
|
||||
|
||||
it('validates the video input shape in test mode without loading it', async () => {
|
||||
expect(await testMode({ input_video: mp4DataUri })).toBe(SAMPLE_URL);
|
||||
expect(secureFetchMock).not.toHaveBeenCalled();
|
||||
for (const input_video of [42, ' ']) {
|
||||
await expect(testMode({ input_video })).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message:
|
||||
'input_video must be a video URL, data URI or Puter file path',
|
||||
});
|
||||
}
|
||||
await expect(
|
||||
testMode({ input_video: mp4DataUri, input_reference: pngDataUri }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: 'input_reference cannot be combined with input_video',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('streams the video with no task, counts only the prompt, and holds the video input allowance', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { held } = trackHolds();
|
||||
reply();
|
||||
await withActor(actor, () =>
|
||||
generate({ input_video: mp4DataUri, size: '360x640' }),
|
||||
);
|
||||
expect(sent(0).body).toEqual({
|
||||
contents: [{ parts: [{ text: PROMPT }] }],
|
||||
});
|
||||
const request = sent(1);
|
||||
expect(request.body.input).toEqual([
|
||||
{
|
||||
type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
data: MP4.toString('base64'),
|
||||
},
|
||||
{ type: 'text', text: PROMPT },
|
||||
]);
|
||||
expect(request.body.generation_config).toEqual({
|
||||
max_output_tokens: 65536,
|
||||
});
|
||||
expect(request.headers['Content-Length']).toBe(
|
||||
String(Buffer.byteLength(request.text)),
|
||||
);
|
||||
expect(held[0]?.amount).toBe(
|
||||
holdFor(COUNTED_TOKENS + VIDEO_INPUT_ALLOWANCE),
|
||||
);
|
||||
});
|
||||
|
||||
it("streams a video URL and the caller's own Puter file at their exact length", async () => {
|
||||
const user = await makeUser();
|
||||
const clip = Buffer.concat([MP4, Buffer.alloc(4096, 1)]);
|
||||
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout');
|
||||
secureFetchMock.mockResolvedValueOnce(
|
||||
new Response(clip, {
|
||||
headers: { 'content-length': String(clip.byteLength) },
|
||||
}),
|
||||
);
|
||||
reply();
|
||||
await withActor(user.actor, () =>
|
||||
generate({ input_video: 'https://example.com/dog.mp4' }),
|
||||
);
|
||||
expect(secureFetchMock).toHaveBeenCalledWith(
|
||||
'https://example.com/dog.mp4',
|
||||
{ signal: expect.any(AbortSignal) },
|
||||
);
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(60_000);
|
||||
expect(sent(1).body.input[0].data).toBe(clip.toString('base64'));
|
||||
expect(sent(1).headers['Content-Length']).toBe(
|
||||
String(Buffer.byteLength(sent(1).text)),
|
||||
);
|
||||
|
||||
const path = await writeVideo(user, 'dog.tmp', clip);
|
||||
reply();
|
||||
await withActor(user.actor, () => generate({ input_video: path }));
|
||||
expect(sent(3).body.input[0]).toEqual({
|
||||
type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
data: clip.toString('base64'),
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the source video only after the credit hold, and not at all without credit', async () => {
|
||||
const user = await makeUser();
|
||||
const order: string[] = [];
|
||||
const { held } = trackHolds(order);
|
||||
secureFetchMock.mockImplementationOnce(async () => {
|
||||
order.push(held.length ? 'fetch after hold' : 'fetch before hold');
|
||||
return new Response(MP4, {
|
||||
headers: { 'content-length': String(MP4.byteLength) },
|
||||
});
|
||||
});
|
||||
reply();
|
||||
await withActor(user.actor, () =>
|
||||
generate({ input_video: 'https://example.com/dog.mp4' }),
|
||||
);
|
||||
expect(order).toEqual(['fetch after hold', 'release']);
|
||||
|
||||
const path = await writeVideo(user, 'dog.mp4', MP4);
|
||||
const read = vi.spyOn(server.stores.s3Object, 'getObjectStream');
|
||||
hasCreditsSpy.mockResolvedValue(false);
|
||||
countOnly();
|
||||
await expect(
|
||||
withActor(user.actor, () => generate({ input_video: path })),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(secureFetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'releases the hold when the caller cancels while the source loads (pre-aborted %s)',
|
||||
async (preAborted) => {
|
||||
const { actor } = await makeUser();
|
||||
const { held, released } = trackHolds();
|
||||
const abort = new AbortController();
|
||||
if (preAborted) abort.abort();
|
||||
secureFetchMock.mockImplementationOnce(
|
||||
(_url: string, init: { signal: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener(
|
||||
'abort',
|
||||
() => reject(init.signal.reason),
|
||||
{ once: true },
|
||||
);
|
||||
abort.abort();
|
||||
}),
|
||||
);
|
||||
countOnly();
|
||||
|
||||
await expect(
|
||||
withActor(actor, () => {
|
||||
Context.set('abortSignal', abort.signal);
|
||||
return generate({
|
||||
input_video: 'https://example.com/dog.mp4',
|
||||
});
|
||||
}),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(secureFetchMock).toHaveBeenCalledTimes(preAborted ? 0 : 1);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(held).toHaveLength(1);
|
||||
expect(released).toHaveBeenCalledOnce();
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a source that fails while streaming as the caller's input", async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { released } = trackHolds();
|
||||
// Fails past what reading ahead to sniff the type pulls in.
|
||||
const chunks = [
|
||||
MP4,
|
||||
...Array.from({ length: 8 }, () => Buffer.alloc(32 * 1024)),
|
||||
];
|
||||
let pulls = 0;
|
||||
secureFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
const chunk = chunks[pulls++];
|
||||
if (chunk) controller.enqueue(new Uint8Array(chunk));
|
||||
else controller.error(new Error('connection reset'));
|
||||
},
|
||||
}),
|
||||
{ headers: { 'content-length': String(1024 * 1024) } },
|
||||
),
|
||||
);
|
||||
reply();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
generate({ input_video: 'https://example.com/dog.mp4' }),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: 'Failed to read input_video',
|
||||
});
|
||||
expect(released).toHaveBeenCalledOnce();
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an image as the source video without generating', async () => {
|
||||
const user = await makeUser();
|
||||
const { released } = trackHolds();
|
||||
countOnly();
|
||||
await expect(
|
||||
withActor(user.actor, () => generate({ input_video: pngDataUri })),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: 'input_video must be an MP4, QuickTime or WebM video',
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(released).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('refuses a data URI over 1 MiB and a file over 32 MiB with 413', async () => {
|
||||
const user = await makeUser();
|
||||
const inline = Buffer.alloc(1024 * 1024 + 1);
|
||||
MP4.copy(inline);
|
||||
await expect(
|
||||
withActor(user.actor, () =>
|
||||
generate({
|
||||
input_video: `data:video/mp4;base64,${inline.toString('base64')}`,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 413,
|
||||
legacyCode: 'input_too_large',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
const big = Buffer.alloc(GEMINI_OMNI_MAX_VIDEO_BYTES + 1);
|
||||
MP4.copy(big);
|
||||
const path = await writeVideo(user, 'big.mp4', big);
|
||||
countOnly();
|
||||
await expect(
|
||||
withActor(user.actor, () => generate({ input_video: path })),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 413,
|
||||
legacyCode: 'input_too_large',
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1332,7 +1600,7 @@ describe('GeminiVideoProvider credit hold', () => {
|
||||
|
||||
// Input plus verified 4K video usage and a separate text allowance.
|
||||
expect(HOLD).toBe(363_065_100);
|
||||
expect(held).toEqual([{ amount: HOLD, ttlMs: 11 * 60 * 1000 }]);
|
||||
expect(held).toEqual([{ amount: HOLD, ttlMs: 15 * 60 * 1000 }]);
|
||||
expect(heldDuringCall).toBe(HOLD);
|
||||
await vi.waitFor(async () =>
|
||||
expect(
|
||||
|
||||
Loaded 100 of 496 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user