mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c41db34b | ||
|
|
e4c6d4ab5d | ||
|
|
b9565ef17a | ||
|
|
3486f20543 | ||
|
|
e4888592c7 | ||
|
|
41b519ee75 | ||
|
|
120e2e8bdf | ||
|
|
d71d93c32c | ||
|
|
ec261224a0 | ||
|
|
1d0784c13a | ||
|
|
a9b60630f5 | ||
|
|
b926ad8d1d | ||
|
|
885ea54ef4 | ||
|
|
291aee2e53 | ||
|
|
60bfe4d54e | ||
|
|
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 |
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
|
||||
|
||||
@@ -185,9 +185,10 @@ Recipes are tested, copy-pasteable patterns for a task, one layer above the per-
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -492,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.
|
||||
|
||||
+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
+91
-79
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -501,6 +501,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';
|
||||
@@ -688,17 +692,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 +722,7 @@ export class DDBClient extends PuterClient {
|
||||
region,
|
||||
endpoint,
|
||||
}: {
|
||||
credentials: { accessKeyId: string; secretAccessKey: string };
|
||||
credentials: AwsCredentials;
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
}) {
|
||||
|
||||
@@ -679,6 +679,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';
|
||||
@@ -6314,29 +6315,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 +6722,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 +6740,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(
|
||||
@@ -8154,15 +8136,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 +8160,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 +8174,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 +8464,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()),
|
||||
@@ -10334,17 +10296,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),
|
||||
});
|
||||
|
||||
@@ -111,6 +111,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 +596,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 +630,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 +666,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 +691,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 +708,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 +1940,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 +2174,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 +2230,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 +2265,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 +2399,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.',
|
||||
@@ -4122,7 +4130,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 +4204,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 +4457,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 +4467,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 +4476,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 +4629,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 +4663,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'));
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
@@ -32,7 +32,6 @@ 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';
|
||||
@@ -60,7 +59,6 @@ export const puterControllers = {
|
||||
notification: NotificationController,
|
||||
events: EventsController,
|
||||
share: ShareController,
|
||||
team: TeamController,
|
||||
webdav: WebDAVController,
|
||||
oidc: OIDCController,
|
||||
wisp: WispController,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
@@ -676,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 };
|
||||
|
||||
@@ -852,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),
|
||||
|
||||
@@ -99,6 +99,18 @@ 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.
|
||||
@@ -195,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();
|
||||
@@ -262,7 +276,8 @@ 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}`
|
||||
req.subdomains?.length === 1 &&
|
||||
subdomainLabelOf(hostName, domain) === active
|
||||
? claim
|
||||
: undefined;
|
||||
if (claimed) {
|
||||
@@ -292,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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ import { runWithContext } from '../../core/context.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';
|
||||
@@ -1645,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)]);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -688,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 {
|
||||
@@ -241,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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ 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';
|
||||
@@ -102,6 +103,11 @@ beforeAll(async () => {
|
||||
// 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;
|
||||
@@ -506,6 +512,54 @@ describe('VideoGenerationDriver.generate provider routing', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -947,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();
|
||||
|
||||
@@ -980,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();
|
||||
|
||||
@@ -1025,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)]);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ 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,
|
||||
@@ -50,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,
|
||||
@@ -63,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';
|
||||
@@ -81,7 +85,7 @@ const tierForPixels = ({
|
||||
* Driver implementing the `puter-video-generation` interface.
|
||||
*
|
||||
* Manages multiple upstream providers (Together, BytePlus/Seedance, Gemini,
|
||||
* Kling) and handles model resolution, provider routing, and parameter
|
||||
* 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.
|
||||
*
|
||||
@@ -98,6 +102,7 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
'byteplus-video-generation',
|
||||
'gemini-video-generation',
|
||||
'kling-video-generation',
|
||||
'minimax-video-generation',
|
||||
];
|
||||
readonly isDefault = true;
|
||||
|
||||
@@ -293,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';
|
||||
@@ -468,6 +473,26 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
{ 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 -----------------------------------------------------------
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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 MiniMax video provider.
|
||||
*
|
||||
* Generates a 4s 480P clip on minimax-h3-lite ($0.10). Skipped when
|
||||
* `PUTER_TEST_AI_MINIMAX_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { MiniMaxVideoProvider } from './MiniMaxVideoProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_MINIMAX_API_KEY';
|
||||
// Task-based generation can take a couple of minutes.
|
||||
const VIDEO_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'MiniMaxVideoProvider (integration)',
|
||||
() => {
|
||||
it(
|
||||
'generates a video URL from minimax-h3-lite',
|
||||
{ timeout: VIDEO_TIMEOUT_MS },
|
||||
async () => {
|
||||
const provider = new MiniMaxVideoProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
|
||||
const url = await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'minimax-h3-lite',
|
||||
prompt: 'a red ball rolls to the right',
|
||||
resolution: '480p',
|
||||
seconds: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(typeof url).toBe('string');
|
||||
expect((url as string).startsWith('https://')).toBe(true);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,876 @@
|
||||
/*
|
||||
* 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 MiniMaxVideoProvider.
|
||||
*
|
||||
* Boots a real PuterServer and constructs the provider against the live wired
|
||||
* `MeteringService`. MiniMax's task APIs are called through global `fetch`,
|
||||
* which is stubbed as the network boundary.
|
||||
*/
|
||||
|
||||
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 { IGenerateVideoParams } from '../../types.js';
|
||||
import { MiniMaxVideoProvider } from './MiniMaxVideoProvider.js';
|
||||
import { MINIMAX_VIDEO_GENERATION_MODELS } from './models.js';
|
||||
|
||||
// -- Test harness ----------------------------------------------------
|
||||
|
||||
let server: PuterServer;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let remainingUsageSpy: MockInstance<MeteringService['getRemainingUsage']>;
|
||||
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-video',
|
||||
);
|
||||
|
||||
const makeProvider = (config: { apiKey?: string; apiBaseUrl?: string } = {}) =>
|
||||
new MiniMaxVideoProvider(
|
||||
{
|
||||
apiKey: config.apiKey ?? 'test-key',
|
||||
...(config.apiBaseUrl ? { apiBaseUrl: config.apiBaseUrl } : {}),
|
||||
pollIntervalMs: 1,
|
||||
},
|
||||
aiMetering(),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
remainingUsageSpy = vi.spyOn(server.services.metering, 'getRemainingUsage');
|
||||
remainingUsageSpy.mockResolvedValue(Number.MAX_SAFE_INTEGER);
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
incrementUsageSpy.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
const ok = { status_code: 0, status_msg: 'success' };
|
||||
|
||||
const h3Task = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'task-1',
|
||||
model: 'MiniMax-H3',
|
||||
status: 'succeeded',
|
||||
content: { url: 'https://minimax.example/out.mp4' },
|
||||
resolution: '768P',
|
||||
duration: 5,
|
||||
usage: { output_seconds: 5 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Queue the v2 create response followed by task query responses. */
|
||||
const mockH3Flow = (...polled: unknown[]) => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ task_id: 'task-1' }));
|
||||
for (const task of polled) {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ task }));
|
||||
}
|
||||
};
|
||||
|
||||
const hailuoTask = (status: string, fileId = 'file-1') => ({
|
||||
task_id: 'task-1',
|
||||
status,
|
||||
file_id: status === 'Success' ? fileId : '',
|
||||
base_resp: ok,
|
||||
});
|
||||
|
||||
/** Queue the v1 create, query and file retrieve responses. */
|
||||
const mockHailuoFlow = (...polled: unknown[]) => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({ task_id: 'task-1', base_resp: ok }),
|
||||
);
|
||||
for (const task of polled) {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(task));
|
||||
}
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
file: {
|
||||
file_id: 1,
|
||||
download_url: 'https://minimax.example/hailuo.mp4',
|
||||
},
|
||||
base_resp: ok,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const sentUrl = (callIndex = 0) => String(fetchSpy.mock.calls[callIndex]![0]);
|
||||
|
||||
const sentBody = (callIndex = 0): Record<string, any> =>
|
||||
JSON.parse(
|
||||
(fetchSpy.mock.calls[callIndex]![1] as RequestInit).body as string,
|
||||
);
|
||||
|
||||
const generate = (params: Partial<IGenerateVideoParams>) =>
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'a ball rolls', ...params }),
|
||||
);
|
||||
|
||||
const rate = (id: string, key: string) =>
|
||||
MINIMAX_VIDEO_GENERATION_MODELS.find((m) => m.id === id)!.costs![key]!;
|
||||
|
||||
// -- Construction / catalog ------------------------------------------
|
||||
|
||||
describe('MiniMaxVideoProvider construction and catalog', () => {
|
||||
it('throws when no apiKey is supplied', () => {
|
||||
expect(
|
||||
() => new MiniMaxVideoProvider({ apiKey: '' }, aiMetering()),
|
||||
).toThrow(/API key/i);
|
||||
});
|
||||
|
||||
it('defaults to MiniMax H3 and lists the seven models', () => {
|
||||
const provider = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe('minimax-h3');
|
||||
expect(provider.models().map((m) => m.id)).toEqual([
|
||||
'minimax-h3',
|
||||
'minimax-h3-highspeed',
|
||||
'minimax-h3-lite',
|
||||
'minimax-h3-max',
|
||||
'minimax-hailuo-2.3',
|
||||
'minimax-hailuo-2.3-fast',
|
||||
'minimax-hailuo-02',
|
||||
]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -- H3 request shape ------------------------------------------------
|
||||
|
||||
describe('MiniMaxVideoProvider.generate H3 request shape', () => {
|
||||
it('creates a v2 task with a ratio and polls it', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
const result = await generate({
|
||||
model: 'minimax-h3-lite',
|
||||
seconds: 6,
|
||||
size: '480p',
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
});
|
||||
|
||||
expect(result).toBe('https://minimax.example/out.mp4');
|
||||
expect(sentUrl()).toBe('https://api.minimax.io/v2/video_generation');
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit & {
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers.Authorization).toBe('Bearer test-key');
|
||||
expect(sentBody()).toEqual({
|
||||
model: 'MiniMax-H3-Lite',
|
||||
content: [{ type: 'text', text: 'a ball rolls' }],
|
||||
duration: 6,
|
||||
resolution: '480P',
|
||||
ratio: '9:16',
|
||||
});
|
||||
expect(sentUrl(1)).toBe(
|
||||
'https://api.minimax.io/v2/query/video_generation/task-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the model name as MiniMax spells it and 2K upper-cased', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({ model: 'MiniMax:minimax/minimax-h3', size: '2K' });
|
||||
expect(sentBody()).toMatchObject({
|
||||
model: 'MiniMax-H3',
|
||||
resolution: '2K',
|
||||
duration: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults the ratio to 16:9 and ignores ratios MiniMax lacks', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({ model: 'minimax-h3', width: 1000, height: 300 });
|
||||
expect(sentBody().ratio).toBe('16:9');
|
||||
});
|
||||
|
||||
it('sends frames with roles and no ratio', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({
|
||||
model: 'minimax-h3',
|
||||
input_reference: 'data:image/png;base64,AAAA',
|
||||
last_frame: 'https://example.com/last.png',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
const body = sentBody();
|
||||
expect(body.content).toEqual([
|
||||
{ type: 'text', text: 'a ball rolls' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,AAAA' },
|
||||
role: 'first_frame',
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/last.png' },
|
||||
role: 'last_frame',
|
||||
},
|
||||
]);
|
||||
expect('ratio' in body).toBe(false);
|
||||
});
|
||||
|
||||
it('takes a last frame on its own', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({
|
||||
model: 'minimax-h3',
|
||||
last_frame: 'https://example.com/last.png',
|
||||
});
|
||||
expect(
|
||||
sentBody().content.map((c: { role?: string }) => c.role),
|
||||
).toEqual([undefined, 'last_frame']);
|
||||
});
|
||||
|
||||
it('wraps raw base64 as a PNG data URI', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({ model: 'minimax-h3', input_reference: 'BBBB' });
|
||||
expect(sentBody().content[1].image_url.url).toBe(
|
||||
'data:image/png;base64,BBBB',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends references with a ratio', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({
|
||||
model: 'minimax-h3-max',
|
||||
reference_images: ['https://example.com/a.png', 'CCCC'],
|
||||
width: 4,
|
||||
height: 3,
|
||||
});
|
||||
const body = sentBody();
|
||||
expect(body.content).toEqual([
|
||||
{ type: 'text', text: 'a ball rolls' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/a.png' },
|
||||
role: 'reference_image',
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,CCCC' },
|
||||
role: 'reference_image',
|
||||
},
|
||||
]);
|
||||
expect(body.ratio).toBe('4:3');
|
||||
});
|
||||
|
||||
it("drops the chat API's /v1 from a base URL override", async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await withTestActor(() =>
|
||||
makeProvider({
|
||||
apiBaseUrl: 'https://api.minimaxi.com/v1/',
|
||||
}).generate({ prompt: 'hi' }),
|
||||
);
|
||||
expect(sentUrl()).toBe('https://api.minimaxi.com/v2/video_generation');
|
||||
});
|
||||
|
||||
it('keeps polling while the task is queued or running', async () => {
|
||||
mockH3Flow(
|
||||
h3Task({ status: 'queued', content: undefined, usage: {} }),
|
||||
h3Task({ status: 'running', content: undefined, usage: {} }),
|
||||
h3Task(),
|
||||
);
|
||||
const result = await generate({});
|
||||
expect(result).toBe('https://minimax.example/out.mp4');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('treats a rate-limited poll as a missed poll', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ task_id: 'task-1' }));
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'rate_limit_error',
|
||||
message: 'rate limit (1002)',
|
||||
},
|
||||
},
|
||||
429,
|
||||
),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ task: h3Task() }));
|
||||
const result = await generate({});
|
||||
expect(result).toBe('https://minimax.example/out.mp4');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('returns the sample clip in test mode without calling MiniMax', async () => {
|
||||
const result = await generate({ test_mode: true });
|
||||
expect(result).toBe('https://assets.puter.site/txt2vid.mp4');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -- Hailuo request shape --------------------------------------------
|
||||
|
||||
describe('MiniMaxVideoProvider.generate Hailuo request shape', () => {
|
||||
it('creates a v1 task, polls it and exchanges the file id for a URL', async () => {
|
||||
mockHailuoFlow(hailuoTask('Preparing'), hailuoTask('Success'));
|
||||
const result = await generate({
|
||||
model: 'minimax-hailuo-2.3',
|
||||
seconds: 10,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
|
||||
expect(result).toBe('https://minimax.example/hailuo.mp4');
|
||||
expect(sentUrl()).toBe('https://api.minimax.io/v1/video_generation');
|
||||
expect(sentBody()).toEqual({
|
||||
model: 'MiniMax-Hailuo-2.3',
|
||||
prompt: 'a ball rolls',
|
||||
duration: 10,
|
||||
resolution: '768P',
|
||||
});
|
||||
expect(sentUrl(1)).toBe(
|
||||
'https://api.minimax.io/v1/query/video_generation?task_id=task-1',
|
||||
);
|
||||
expect(sentUrl(3)).toBe(
|
||||
'https://api.minimax.io/v1/files/retrieve?file_id=file-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to 6 seconds at 1080p, which takes no 10-second clip', async () => {
|
||||
mockHailuoFlow(hailuoTask('Success'));
|
||||
await generate({
|
||||
model: 'minimax-hailuo-2.3-fast',
|
||||
seconds: 10,
|
||||
size: '1080p',
|
||||
input_reference: 'https://example.com/first.png',
|
||||
});
|
||||
expect(sentBody()).toMatchObject({
|
||||
model: 'MiniMax-Hailuo-2.3-Fast',
|
||||
duration: 6,
|
||||
resolution: '1080P',
|
||||
first_frame_image: 'https://example.com/first.png',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['768p', '1080p'])('sends first and last frames at %s', async (size) => {
|
||||
mockHailuoFlow(hailuoTask('Success'));
|
||||
await generate({
|
||||
model: 'minimax-hailuo-02',
|
||||
size,
|
||||
input_reference: 'DDDD',
|
||||
last_frame: 'https://example.com/last.png',
|
||||
});
|
||||
expect(sentBody()).toEqual({
|
||||
model: 'MiniMax-Hailuo-02',
|
||||
prompt: 'a ball rolls',
|
||||
duration: 6,
|
||||
resolution: size.toUpperCase(),
|
||||
first_frame_image: 'data:image/png;base64,DDDD',
|
||||
last_frame_image: 'https://example.com/last.png',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// -- Validation ------------------------------------------------------
|
||||
|
||||
describe('MiniMaxVideoProvider.generate rejects invalid input before calling MiniMax', () => {
|
||||
it.each<[string, Partial<IGenerateVideoParams>]>([
|
||||
['a blank prompt', { prompt: ' ' }],
|
||||
[
|
||||
'a prompt over 7000 characters on H3',
|
||||
{ model: 'minimax-h3', prompt: 'x'.repeat(7001) },
|
||||
],
|
||||
[
|
||||
'a prompt over 2000 characters on Hailuo',
|
||||
{ model: 'minimax-hailuo-2.3', prompt: 'x'.repeat(2001) },
|
||||
],
|
||||
[
|
||||
'a source video',
|
||||
{ model: 'minimax-h3', input_video: 'https://example.com/v.mp4' },
|
||||
],
|
||||
[
|
||||
'reference images on Hailuo',
|
||||
{
|
||||
model: 'minimax-hailuo-02',
|
||||
reference_images: ['https://example.com/r.png'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'reference images with a first frame on H3',
|
||||
{
|
||||
model: 'minimax-h3',
|
||||
input_reference: 'https://example.com/f.png',
|
||||
reference_images: ['https://example.com/r.png'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'more than 5 images on H3',
|
||||
{
|
||||
model: 'minimax-h3',
|
||||
reference_images: Array(6).fill('https://example.com/r.png'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'more than 2 images on H3 Max',
|
||||
{
|
||||
model: 'minimax-h3-max',
|
||||
reference_images: Array(3).fill('https://example.com/r.png'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'a last frame on Hailuo 2.3',
|
||||
{
|
||||
model: 'minimax-hailuo-2.3',
|
||||
input_reference: 'https://example.com/f.png',
|
||||
last_frame: 'https://example.com/l.png',
|
||||
},
|
||||
],
|
||||
[
|
||||
'a last frame without a first frame on Hailuo 02',
|
||||
{
|
||||
model: 'minimax-hailuo-02',
|
||||
last_frame: 'https://example.com/l.png',
|
||||
},
|
||||
],
|
||||
[
|
||||
'512p without a first frame on Hailuo 02',
|
||||
{ model: 'minimax-hailuo-02', size: '512p' },
|
||||
],
|
||||
[
|
||||
'a missing first frame on Hailuo 2.3 Fast',
|
||||
{ model: 'minimax-hailuo-2.3-fast' },
|
||||
],
|
||||
[
|
||||
'512p with first and last frames on Hailuo 02',
|
||||
{
|
||||
model: 'minimax-hailuo-02',
|
||||
size: '512p',
|
||||
input_reference: 'https://example.com/first.png',
|
||||
last_frame: 'https://example.com/last.png',
|
||||
},
|
||||
],
|
||||
[
|
||||
'a non-string image',
|
||||
{ model: 'minimax-h3', input_reference: 42 as never },
|
||||
],
|
||||
[
|
||||
'a non-array reference_images',
|
||||
{ model: 'minimax-h3', reference_images: 'x' as never },
|
||||
],
|
||||
])('rejects %s', async (_label, params) => {
|
||||
fetchSpy.mockRejectedValue(new Error('Unexpected MiniMax request'));
|
||||
const reserveCreditsSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'reserveCredits',
|
||||
);
|
||||
await expect(generate(params)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(reserveCreditsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a 7000-character prompt on H3', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({ model: 'minimax-h3', prompt: 'x'.repeat(7000) });
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts five images on H3', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({
|
||||
model: 'minimax-h3',
|
||||
reference_images: Array(5).fill('https://example.com/r.png'),
|
||||
});
|
||||
expect(sentBody().content).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Billing ---------------------------------------------------------
|
||||
|
||||
describe('MiniMaxVideoProvider billing', () => {
|
||||
it.each<[Partial<IGenerateVideoParams>, string, string, number]>([
|
||||
[
|
||||
{ model: 'minimax-h3', seconds: 5 },
|
||||
'minimax-h3',
|
||||
'per-second:768p',
|
||||
5,
|
||||
],
|
||||
[
|
||||
{ model: 'minimax-h3', seconds: 8, size: '2k' },
|
||||
'minimax-h3',
|
||||
'per-second:2k',
|
||||
8,
|
||||
],
|
||||
[
|
||||
{ model: 'minimax-h3-lite', seconds: 4, size: '480p' },
|
||||
'minimax-h3-lite',
|
||||
'per-second:480p',
|
||||
4,
|
||||
],
|
||||
])(
|
||||
'meters %o per second under its cost key',
|
||||
async (params, id, key, secs) => {
|
||||
mockH3Flow(
|
||||
h3Task({ duration: secs, usage: { output_seconds: secs } }),
|
||||
);
|
||||
await generate(params);
|
||||
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
|
||||
const [, usageType, amount, cost] =
|
||||
incrementUsageSpy.mock.calls[0]!;
|
||||
expect(usageType).toBe(`minimax-video-generation:${id}:${key}`);
|
||||
expect(amount).toBe(secs);
|
||||
expect(cost).toBeCloseTo(secs * rate(id, key) * 1_000_000);
|
||||
},
|
||||
);
|
||||
|
||||
it('bills the seconds of output the task reports', async () => {
|
||||
mockH3Flow(h3Task({ duration: 5, usage: { output_seconds: 4 } }));
|
||||
await generate({ model: 'minimax-h3', seconds: 5 });
|
||||
const [, , amount, cost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(amount).toBe(4);
|
||||
expect(cost).toBeCloseTo(4 * 8 * 1_000_000);
|
||||
});
|
||||
|
||||
it('bills the requested seconds when the task reports no usage', async () => {
|
||||
mockH3Flow(h3Task({ duration: undefined, usage: undefined }));
|
||||
await generate({ model: 'minimax-h3', seconds: 7 });
|
||||
expect(incrementUsageSpy.mock.calls[0]![2]).toBe(7);
|
||||
});
|
||||
|
||||
it('shortens an H3 clip to what the balance buys', async () => {
|
||||
remainingUsageSpy.mockResolvedValue(6 * 8 * 1_000_000);
|
||||
mockH3Flow(h3Task({ duration: 6, usage: { output_seconds: 6 } }));
|
||||
await generate({ model: 'minimax-h3', seconds: 10 });
|
||||
expect(sentBody().duration).toBe(6);
|
||||
});
|
||||
|
||||
it('rejects with insufficient_funds before creating an H3 task it cannot afford', async () => {
|
||||
remainingUsageSpy.mockResolvedValue(1000);
|
||||
await expect(generate({ model: 'minimax-h3' })).rejects.toMatchObject({
|
||||
statusCode: 402,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each<[Partial<IGenerateVideoParams>, string, string]>([
|
||||
[
|
||||
{ model: 'minimax-hailuo-2.3', seconds: 10 },
|
||||
'minimax-hailuo-2.3',
|
||||
'per-video:768p:10',
|
||||
],
|
||||
[
|
||||
{ model: 'minimax-hailuo-02', size: '1080p' },
|
||||
'minimax-hailuo-02',
|
||||
'per-video:1080p:6',
|
||||
],
|
||||
[
|
||||
{
|
||||
model: 'minimax-hailuo-02',
|
||||
size: '512p',
|
||||
seconds: 10,
|
||||
input_reference: 'https://example.com/f.png',
|
||||
},
|
||||
'minimax-hailuo-02',
|
||||
'per-video:512p:10',
|
||||
],
|
||||
])('meters a Hailuo clip %o as one video', async (params, id, key) => {
|
||||
mockHailuoFlow(hailuoTask('Success'));
|
||||
await generate(params);
|
||||
const [, usageType, amount, cost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(usageType).toBe(`minimax-video-generation:${id}:${key}`);
|
||||
expect(amount).toBe(1);
|
||||
expect(cost).toBe(rate(id, key) * 1_000_000);
|
||||
});
|
||||
|
||||
it('rejects with insufficient_funds before creating a Hailuo task it cannot afford', async () => {
|
||||
remainingUsageSpy.mockResolvedValue(1000);
|
||||
await expect(
|
||||
generate({ model: 'minimax-hailuo-2.3' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 402,
|
||||
legacyCode: 'insufficient_funds',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -- Failures --------------------------------------------------------
|
||||
|
||||
describe('MiniMaxVideoProvider failures', () => {
|
||||
it.each(['failed', 'cancelled'])(
|
||||
'maps a %s H3 task to upstream_failed and meters nothing',
|
||||
async (status) => {
|
||||
mockH3Flow(
|
||||
h3Task({
|
||||
status,
|
||||
content: undefined,
|
||||
error: { code: '1000', message: 'unknown error' },
|
||||
}),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('maps an H3 task failed by the content filter to moderation_flagged', async () => {
|
||||
mockH3Flow(
|
||||
h3Task({
|
||||
status: 'failed',
|
||||
content: undefined,
|
||||
error: {
|
||||
code: '1026',
|
||||
message: 'video description contains sensitive content',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
code: 'moderation_flagged',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('carries a v2 parameter rejection with its status and code', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'bad_request_error',
|
||||
message:
|
||||
'invalid params, model MiniMax-H3-Lite does not support resolution 2K (2013)',
|
||||
http_code: '400',
|
||||
},
|
||||
},
|
||||
400,
|
||||
),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
status: 400,
|
||||
code: '2013',
|
||||
provider: 'minimax',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a model MiniMax no longer serves as upstream_failed', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'bad_request_error',
|
||||
message:
|
||||
'invalid params, this model is not supported by /v2/video_generation (2013)',
|
||||
},
|
||||
},
|
||||
400,
|
||||
),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the auth message behind a 401', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'authorized_error',
|
||||
message:
|
||||
'login fail: carry the key in Authorization (1004)',
|
||||
},
|
||||
},
|
||||
401,
|
||||
),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: 'MiniMax rejected the API key',
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces an empty balance as status 402 with the MiniMax code', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'insufficient_balance_error',
|
||||
message: 'insufficient balance (1008)',
|
||||
},
|
||||
},
|
||||
402,
|
||||
),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
status: 402,
|
||||
code: '1008',
|
||||
provider: 'minimax',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a content block at submit to moderation_flagged', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'unprocessable_entity_error',
|
||||
message: 'input new_sensitive (1026)',
|
||||
},
|
||||
},
|
||||
422,
|
||||
),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
code: 'moderation_flagged',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a v1 verdict out of base_resp under HTTP 200', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
task_id: '',
|
||||
base_resp: {
|
||||
status_code: 1008,
|
||||
status_msg: 'insufficient balance',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
generate({ model: 'minimax-hailuo-2.3' }),
|
||||
).rejects.toMatchObject({
|
||||
status: 402,
|
||||
code: '1008',
|
||||
provider: 'minimax',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a Hailuo model MiniMax no longer serves as upstream_failed', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
task_id: '',
|
||||
base_resp: {
|
||||
status_code: 2013,
|
||||
status_msg: 'invalid params, incorrect model param input',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
generate({ model: 'minimax-hailuo-2.3' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a failed Hailuo task to upstream_failed and meters nothing', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({ task_id: 'task-1', base_resp: ok }),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(hailuoTask('Fail')));
|
||||
await expect(
|
||||
generate({ model: 'minimax-hailuo-2.3' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps a Hailuo clip removed by the output filter to moderation_flagged', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({ task_id: 'task-1', base_resp: ok }),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
task_id: 'task-1',
|
||||
status: 'Fail',
|
||||
base_resp: {
|
||||
status_code: 1027,
|
||||
status_msg: 'output new_sensitive',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
generate({ model: 'minimax-hailuo-2.3' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
code: 'moderation_flagged',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails when the finished H3 task has no video', async () => {
|
||||
mockH3Flow(h3Task({ content: {} }));
|
||||
await expect(generate({})).rejects.toThrow(/video URL/);
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails when the Hailuo file has no download URL', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({ task_id: 'task-1', base_resp: ok }),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(hailuoTask('Success')));
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({ file: { file_id: 1 }, base_resp: ok }),
|
||||
);
|
||||
await expect(generate({ model: 'minimax-hailuo-2.3' })).rejects.toThrow(
|
||||
/video URL/,
|
||||
);
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,690 @@
|
||||
/*
|
||||
* 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 { Actor } from '../../../../core/actor.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { insufficientCreditsError } from '../../../../services/metering/enforcement.js';
|
||||
import type { CreditHold } from '../../../../services/metering/types.js';
|
||||
import type { AiMeteringService } from '../../../util/aiCostFactor.js';
|
||||
import { readBoundedBody } from '../../../util/fileInput.js';
|
||||
import { toUrlOrDataUri } from '../../../util/imageInput.js';
|
||||
import { sanitizeUpstreamMessage } from '../../../util/upstreamErrors.js';
|
||||
import { capSecondsToRemainingCredits } from '../../creditCap.js';
|
||||
import type { IGenerateVideoParams, IVideoModel } from '../../types.js';
|
||||
import { VideoProvider } from '../VideoProvider.js';
|
||||
import {
|
||||
pollUntilSettled,
|
||||
VIDEO_HOLD_TTL_MS,
|
||||
videoJobFailure,
|
||||
} from '../polling.js';
|
||||
import {
|
||||
MINIMAX_VIDEO_GENERATION_MODELS,
|
||||
MINIMAX_VIDEO_SPECS,
|
||||
type MiniMaxVideoSpec,
|
||||
} from './models.js';
|
||||
|
||||
const DEFAULT_TEST_VIDEO_URL = 'https://assets.puter.site/txt2vid.mp4';
|
||||
const DEFAULT_BASE_URL = 'https://api.minimax.io';
|
||||
const DEFAULT_MODEL = 'minimax-h3';
|
||||
const DEFAULT_POLL_INTERVAL_MS = 5_000;
|
||||
const REQUEST_TIMEOUT_MS = 60_000;
|
||||
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
const PROVIDER = 'minimax';
|
||||
|
||||
const ASPECT_RATIOS = ['21:9', '16:9', '4:3', '1:1', '3:4', '9:16'];
|
||||
const DEFAULT_ASPECT_RATIO = '16:9';
|
||||
|
||||
// MiniMax's business codes, from the trailing "(NNNN)" of a v2 error message
|
||||
// or `base_resp.status_code` of a v1 reply.
|
||||
// https://platform.minimax.io/docs/api-reference/errorcode
|
||||
const AUTH_CODES = new Set([1004, 2049]);
|
||||
const BALANCE_CODES = new Set([1008, 2053, 2056, 2067]);
|
||||
const RATE_LIMIT_CODES = new Set([1002, 1039, 1041, 1045]);
|
||||
const MODERATION_CODES = new Set([1026, 1027]);
|
||||
const MODEL_UNAVAILABLE_CODES = new Set([2022, 2061, 2068, 2069]);
|
||||
const INVALID_PARAM_CODES = new Set([2013, 2014]);
|
||||
/** A 2013 about the model itself, which means the catalog has drifted. */
|
||||
const UNSUPPORTED_MODEL_PATTERN =
|
||||
/this model is not supported|incorrect model param/i;
|
||||
|
||||
type MiniMaxVideoConfig = {
|
||||
apiKey: string;
|
||||
apiBaseUrl?: string;
|
||||
/** Test hook: polling cadence for task status checks. */
|
||||
pollIntervalMs?: number;
|
||||
};
|
||||
|
||||
interface MiniMaxEnvelope {
|
||||
base_resp?: { status_code?: number; status_msg?: string };
|
||||
error?: { type?: string; message?: string; http_code?: string };
|
||||
}
|
||||
|
||||
interface H3Task {
|
||||
id: string;
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
|
||||
content?: { url?: string };
|
||||
error?: { code?: string; message?: string };
|
||||
duration?: number;
|
||||
usage?: { output_seconds?: number };
|
||||
}
|
||||
|
||||
interface HailuoTask {
|
||||
task_id?: string;
|
||||
status?: 'Preparing' | 'Queueing' | 'Processing' | 'Success' | 'Fail';
|
||||
file_id?: string;
|
||||
}
|
||||
|
||||
const badRequest = (message: string) =>
|
||||
new HttpError(400, message, { legacyCode: 'bad_request' });
|
||||
|
||||
const present = (value: unknown): boolean =>
|
||||
value !== undefined && value !== null;
|
||||
|
||||
const statusForCode = (code: number, httpStatus: number): number => {
|
||||
if (AUTH_CODES.has(code)) return 401;
|
||||
if (BALANCE_CODES.has(code)) return 402;
|
||||
if (RATE_LIMIT_CODES.has(code)) return 429;
|
||||
if (INVALID_PARAM_CODES.has(code)) return 400;
|
||||
return httpStatus >= 400 ? httpStatus : 502;
|
||||
};
|
||||
|
||||
/** The code a v2 error message ends with, e.g. "invalid params (2013)". */
|
||||
const parseCode = (message: string): number | undefined => {
|
||||
const match = message.match(/\((\d{4,5})\)\s*$/);
|
||||
return match ? Number.parseInt(match[1], 10) : undefined;
|
||||
};
|
||||
|
||||
/** A request MiniMax refused, shaped for `translateProviderError`. */
|
||||
const minimaxError = (
|
||||
code: number | undefined,
|
||||
message: string,
|
||||
status: number,
|
||||
): Error => {
|
||||
// A model MiniMax doesn't serve is Puter's pick, so the catalog has
|
||||
// drifted from MiniMax's: our fault, not the caller's, and worth
|
||||
// someone's attention.
|
||||
if (
|
||||
code !== undefined &&
|
||||
(MODEL_UNAVAILABLE_CODES.has(code) ||
|
||||
(code === 2013 && UNSUPPORTED_MODEL_PATTERN.test(message)))
|
||||
) {
|
||||
return new HttpError(
|
||||
502,
|
||||
'MiniMax does not support the model Puter requested',
|
||||
{
|
||||
legacyCode: 'upstream_failed',
|
||||
alarmSeverity: 'warning',
|
||||
fields: {
|
||||
provider: PROVIDER,
|
||||
upstreamCode: String(code),
|
||||
upstreamMessage: sanitizeUpstreamMessage(message),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
if (code !== undefined && MODERATION_CODES.has(code)) {
|
||||
return new HttpError(
|
||||
400,
|
||||
sanitizeUpstreamMessage(message) ||
|
||||
'MiniMax rejected the request under its content policy',
|
||||
{
|
||||
legacyCode: 'bad_request',
|
||||
code: 'moderation_flagged',
|
||||
fields: { provider: PROVIDER, upstreamCode: String(code) },
|
||||
},
|
||||
);
|
||||
}
|
||||
// MiniMax's auth messages point at its own console; the caller can't act
|
||||
// on them.
|
||||
const isAuthFailure = code !== undefined && AUTH_CODES.has(code);
|
||||
return Object.assign(
|
||||
new Error(isAuthFailure ? 'MiniMax rejected the API key' : message),
|
||||
{
|
||||
status:
|
||||
code === undefined
|
||||
? status >= 400
|
||||
? status
|
||||
: 502
|
||||
: statusForCode(code, status),
|
||||
code: code === undefined ? undefined : String(code),
|
||||
provider: PROVIDER,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* MiniMax video generation (H3 and Hailuo).
|
||||
*
|
||||
* H3 models go through the v2 task API: a `content` list of text, frames and
|
||||
* references creates a task that is polled until it leaves queued/running, and
|
||||
* the clip's URL comes back on the task. Billing is per second of output at a
|
||||
* rate set by resolution. Hailuo models go through the v1 API, which takes a
|
||||
* prompt and frame images, is polled on its own status endpoint and hands back
|
||||
* a file id to exchange for a download URL. They are billed per clip, by
|
||||
* resolution and length.
|
||||
* https://platform.minimax.io/docs/api-reference/video-generation-v2-create
|
||||
*/
|
||||
export class MiniMaxVideoProvider extends VideoProvider {
|
||||
#apiKey: string;
|
||||
#baseUrl: string;
|
||||
#pollIntervalMs: number;
|
||||
#meteringService: AiMeteringService;
|
||||
|
||||
constructor(
|
||||
config: MiniMaxVideoConfig,
|
||||
meteringService: AiMeteringService,
|
||||
) {
|
||||
super();
|
||||
if (!config.apiKey) {
|
||||
throw new Error('MiniMax video generation requires an API key');
|
||||
}
|
||||
this.#apiKey = config.apiKey;
|
||||
// The shared chat block's base URL ends in the chat API's `/v1`; the
|
||||
// video API lives beside it on the same host.
|
||||
this.#baseUrl = (config.apiBaseUrl ?? DEFAULT_BASE_URL)
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/v1$/, '');
|
||||
this.#pollIntervalMs =
|
||||
config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
||||
this.#meteringService = meteringService;
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
models(): IVideoModel[] {
|
||||
return MINIMAX_VIDEO_GENERATION_MODELS;
|
||||
}
|
||||
|
||||
async generate(params: IGenerateVideoParams): Promise<unknown> {
|
||||
const {
|
||||
prompt,
|
||||
model: requestedModel,
|
||||
seconds,
|
||||
duration,
|
||||
size,
|
||||
resolution,
|
||||
width,
|
||||
height,
|
||||
input_reference: inputReference,
|
||||
last_frame: lastFrame,
|
||||
reference_images: referenceImages,
|
||||
input_video: inputVideo,
|
||||
test_mode: testMode,
|
||||
} = params ?? {};
|
||||
|
||||
if (typeof prompt !== 'string' || !prompt.trim()) {
|
||||
throw badRequest('prompt must be a non-empty string');
|
||||
}
|
||||
const model = this.#getModel(requestedModel);
|
||||
const spec = MINIMAX_VIDEO_SPECS[model.id];
|
||||
if (prompt.length > spec.maxPromptChars) {
|
||||
throw badRequest(
|
||||
`${model.id} takes a prompt of at most ${spec.maxPromptChars} characters`,
|
||||
);
|
||||
}
|
||||
if (present(inputVideo)) {
|
||||
throw badRequest(`${model.id} does not accept a source video`);
|
||||
}
|
||||
|
||||
const resolutionKey = this.#resolution(size ?? resolution, model);
|
||||
const firstFrame = this.#image(inputReference, 'input_reference');
|
||||
const endFrame = this.#image(lastFrame, 'last_frame');
|
||||
const references = this.#references(referenceImages);
|
||||
this.#checkInputs(spec, model, resolutionKey, {
|
||||
firstFrame,
|
||||
endFrame,
|
||||
references,
|
||||
});
|
||||
|
||||
if (testMode) {
|
||||
return DEFAULT_TEST_VIDEO_URL;
|
||||
}
|
||||
|
||||
const actor = Context.get('actor') as Actor | undefined;
|
||||
if (!actor) {
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
const requestedSeconds =
|
||||
this.#coerceSeconds(seconds ?? duration) ??
|
||||
model.durationSeconds![0];
|
||||
const aspectRatio = this.#aspectRatio(width, height);
|
||||
const images = { firstFrame, endFrame, references };
|
||||
|
||||
if (spec.api === 'v2') {
|
||||
return this.#generateH3(actor, model, spec, {
|
||||
prompt,
|
||||
resolutionKey,
|
||||
requestedSeconds,
|
||||
aspectRatio,
|
||||
...images,
|
||||
});
|
||||
}
|
||||
return this.#generateHailuo(actor, model, spec, {
|
||||
prompt,
|
||||
resolutionKey,
|
||||
requestedSeconds,
|
||||
...images,
|
||||
});
|
||||
}
|
||||
|
||||
// -- H3 (v2 task API, per second) ---------------------------------------
|
||||
|
||||
async #generateH3(
|
||||
actor: Actor,
|
||||
model: IVideoModel,
|
||||
spec: MiniMaxVideoSpec,
|
||||
input: {
|
||||
prompt: string;
|
||||
resolutionKey: string;
|
||||
requestedSeconds: number;
|
||||
aspectRatio: string | undefined;
|
||||
firstFrame?: string;
|
||||
endFrame?: string;
|
||||
references: string[];
|
||||
},
|
||||
): Promise<string> {
|
||||
const costKey = `per-second:${input.resolutionKey}`;
|
||||
const centsPerSecond = model.costs?.[costKey];
|
||||
if (!centsPerSecond) {
|
||||
throw new Error(
|
||||
`No pricing configured for video model ${model.id} at ${costKey}`,
|
||||
);
|
||||
}
|
||||
const usageType = `minimax-video-generation:${model.id}:${costKey}`;
|
||||
const { seconds: cappedSeconds, hold } =
|
||||
await capSecondsToRemainingCredits({
|
||||
metering: this.#meteringService,
|
||||
actor,
|
||||
usageType,
|
||||
perSecondMicroCents: centsPerSecond * 1_000_000,
|
||||
requestedSeconds: input.requestedSeconds,
|
||||
allowedSeconds: model.durationSeconds,
|
||||
});
|
||||
|
||||
try {
|
||||
const content: Array<Record<string, unknown>> = [
|
||||
{ type: 'text', text: input.prompt },
|
||||
];
|
||||
if (input.firstFrame) {
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: input.firstFrame },
|
||||
role: 'first_frame',
|
||||
});
|
||||
}
|
||||
if (input.endFrame) {
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: input.endFrame },
|
||||
role: 'last_frame',
|
||||
});
|
||||
}
|
||||
for (const url of input.references) {
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url },
|
||||
role: 'reference_image',
|
||||
});
|
||||
}
|
||||
const body: Record<string, unknown> = {
|
||||
model: spec.apiModel,
|
||||
content,
|
||||
duration: cappedSeconds,
|
||||
resolution: input.resolutionKey.toUpperCase(),
|
||||
};
|
||||
// A clip from frames takes their shape; MiniMax requires a ratio
|
||||
// for everything else.
|
||||
if (!input.firstFrame && !input.endFrame) {
|
||||
body.ratio = input.aspectRatio ?? DEFAULT_ASPECT_RATIO;
|
||||
}
|
||||
|
||||
const created = await this.#request<{ task_id?: string }>(
|
||||
'POST',
|
||||
'/v2/video_generation',
|
||||
body,
|
||||
);
|
||||
if (!created.task_id) {
|
||||
throw new Error('MiniMax response did not include a task id');
|
||||
}
|
||||
const task = await pollUntilSettled<H3Task>({
|
||||
provider: PROVIDER,
|
||||
providerLabel: 'MiniMax',
|
||||
intervalMs: this.#pollIntervalMs,
|
||||
fetch: async () => {
|
||||
const { task } = await this.#request<{ task?: H3Task }>(
|
||||
'GET',
|
||||
`/v2/query/video_generation/${encodeURIComponent(created.task_id!)}`,
|
||||
);
|
||||
if (!task) {
|
||||
throw new Error('MiniMax task query returned no task');
|
||||
}
|
||||
return task;
|
||||
},
|
||||
isPending: (task) =>
|
||||
task.status === 'queued' || task.status === 'running',
|
||||
});
|
||||
if (task.status !== 'succeeded') {
|
||||
throw videoJobFailure(
|
||||
PROVIDER,
|
||||
task.error?.message || `Video generation ${task.status}`,
|
||||
task.error?.code,
|
||||
);
|
||||
}
|
||||
const url = task.content?.url;
|
||||
if (typeof url !== 'string' || !url.trim()) {
|
||||
throw new Error('MiniMax response did not include a video URL');
|
||||
}
|
||||
|
||||
// MiniMax bills the seconds of output the task reports.
|
||||
const outputSeconds = task.usage?.output_seconds ?? task.duration;
|
||||
const billedSeconds =
|
||||
typeof outputSeconds === 'number' &&
|
||||
Number.isFinite(outputSeconds) &&
|
||||
outputSeconds > 0
|
||||
? Math.max(1, Math.round(outputSeconds))
|
||||
: cappedSeconds;
|
||||
await this.#meteringService.incrementUsage(
|
||||
actor,
|
||||
usageType,
|
||||
billedSeconds,
|
||||
Math.round(billedSeconds * centsPerSecond * 1_000_000),
|
||||
);
|
||||
return url;
|
||||
} finally {
|
||||
await hold.release();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Hailuo (v1 API, per clip) ------------------------------------------
|
||||
|
||||
async #generateHailuo(
|
||||
actor: Actor,
|
||||
model: IVideoModel,
|
||||
spec: MiniMaxVideoSpec,
|
||||
input: {
|
||||
prompt: string;
|
||||
resolutionKey: string;
|
||||
requestedSeconds: number;
|
||||
firstFrame?: string;
|
||||
endFrame?: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
// A clip length the resolution doesn't offer falls back to the first
|
||||
// one it does, the way the driver treats any unsupported duration.
|
||||
const offered = (secs: number) =>
|
||||
model.costs?.[`per-video:${input.resolutionKey}:${secs}`];
|
||||
const clipSeconds = offered(input.requestedSeconds)
|
||||
? input.requestedSeconds
|
||||
: model.durationSeconds!.find(offered)!;
|
||||
const costKey = `per-video:${input.resolutionKey}:${clipSeconds}`;
|
||||
const centsPerVideo = model.costs![costKey]!;
|
||||
const usageType = `minimax-video-generation:${model.id}:${costKey}`;
|
||||
const microCents = centsPerVideo * 1_000_000;
|
||||
const hold: CreditHold | null =
|
||||
await this.#meteringService.reserveAiCredits(
|
||||
actor,
|
||||
usageType,
|
||||
microCents,
|
||||
{ ttlMs: VIDEO_HOLD_TTL_MS },
|
||||
);
|
||||
if (!hold) {
|
||||
throw insufficientCreditsError();
|
||||
}
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
model: spec.apiModel,
|
||||
prompt: input.prompt,
|
||||
duration: clipSeconds,
|
||||
resolution: input.resolutionKey.toUpperCase(),
|
||||
};
|
||||
if (input.firstFrame) body.first_frame_image = input.firstFrame;
|
||||
if (input.endFrame) body.last_frame_image = input.endFrame;
|
||||
|
||||
const created = await this.#request<{ task_id?: string }>(
|
||||
'POST',
|
||||
'/v1/video_generation',
|
||||
body,
|
||||
);
|
||||
if (!created.task_id) {
|
||||
throw new Error('MiniMax response did not include a task id');
|
||||
}
|
||||
const task = await pollUntilSettled<HailuoTask>({
|
||||
provider: PROVIDER,
|
||||
providerLabel: 'MiniMax',
|
||||
intervalMs: this.#pollIntervalMs,
|
||||
fetch: () =>
|
||||
this.#request<HailuoTask>(
|
||||
'GET',
|
||||
`/v1/query/video_generation?task_id=${encodeURIComponent(created.task_id!)}`,
|
||||
),
|
||||
isPending: (task) =>
|
||||
task.status === 'Preparing' ||
|
||||
task.status === 'Queueing' ||
|
||||
task.status === 'Processing',
|
||||
});
|
||||
if (task.status !== 'Success' || !task.file_id) {
|
||||
throw videoJobFailure(
|
||||
PROVIDER,
|
||||
'MiniMax video generation failed',
|
||||
);
|
||||
}
|
||||
// The clip is a file in MiniMax's store; its download link is
|
||||
// minted on request and works for an hour.
|
||||
const { file } = await this.#request<{
|
||||
file?: { download_url?: string };
|
||||
}>(
|
||||
'GET',
|
||||
`/v1/files/retrieve?file_id=${encodeURIComponent(task.file_id)}`,
|
||||
);
|
||||
const url = file?.download_url;
|
||||
if (typeof url !== 'string' || !url.trim()) {
|
||||
throw new Error('MiniMax response did not include a video URL');
|
||||
}
|
||||
|
||||
await this.#meteringService.incrementUsage(
|
||||
actor,
|
||||
usageType,
|
||||
1,
|
||||
microCents,
|
||||
);
|
||||
return url;
|
||||
} finally {
|
||||
await hold.release();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Validation ---------------------------------------------------------
|
||||
|
||||
#checkInputs(
|
||||
spec: MiniMaxVideoSpec,
|
||||
model: IVideoModel,
|
||||
resolutionKey: string,
|
||||
inputs: {
|
||||
firstFrame?: string;
|
||||
endFrame?: string;
|
||||
references: string[];
|
||||
},
|
||||
) {
|
||||
const { firstFrame, endFrame, references } = inputs;
|
||||
if (spec.firstFrameRequired && !firstFrame) {
|
||||
throw badRequest(`${model.id} requires an input_reference image`);
|
||||
}
|
||||
if (endFrame && !spec.lastFrame) {
|
||||
throw badRequest(`${model.id} does not support last_frame`);
|
||||
}
|
||||
if (endFrame && !firstFrame && spec.api === 'v1') {
|
||||
throw badRequest(
|
||||
'last_frame requires a first-frame image via input_reference',
|
||||
);
|
||||
}
|
||||
if (references.length > 0) {
|
||||
if (!spec.references) {
|
||||
throw badRequest(
|
||||
`${model.id} does not support reference_images`,
|
||||
);
|
||||
}
|
||||
if (firstFrame || endFrame) {
|
||||
throw badRequest(
|
||||
'reference_images cannot be combined with input_reference or last_frame',
|
||||
);
|
||||
}
|
||||
}
|
||||
const imageCount =
|
||||
(firstFrame ? 1 : 0) + (endFrame ? 1 : 0) + references.length;
|
||||
if (imageCount > spec.maxImages) {
|
||||
throw badRequest(
|
||||
`${model.id} takes at most ${spec.maxImages} image(s), frames and references together`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
spec.imageOnlyResolutions?.includes(resolutionKey) &&
|
||||
(!firstFrame || endFrame)
|
||||
) {
|
||||
throw badRequest(
|
||||
`${model.id} renders ${resolutionKey} only from an input_reference image without last_frame`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #request<T>(
|
||||
method: 'GET' | 'POST',
|
||||
path: string,
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${this.#baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.#apiKey}`,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const raw = await readBoundedBody(response, MAX_RESPONSE_BYTES, () =>
|
||||
minimaxError(undefined, 'MiniMax response too large', 502),
|
||||
);
|
||||
let payload: MiniMaxEnvelope = {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw.toString('utf8'));
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
payload = parsed as MiniMaxEnvelope;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON; the status alone decides.
|
||||
}
|
||||
// A v1 reply carries its verdict in `base_resp` under HTTP 200; a v2
|
||||
// reply uses the HTTP status and an OpenAI-style `error`.
|
||||
const v1Code = payload.base_resp?.status_code;
|
||||
if (typeof v1Code === 'number' && v1Code !== 0) {
|
||||
throw minimaxError(
|
||||
v1Code,
|
||||
payload.base_resp?.status_msg ||
|
||||
`MiniMax request failed (code ${v1Code})`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
payload.error?.message ||
|
||||
`MiniMax request failed (status ${response.status})`;
|
||||
throw minimaxError(parseCode(message), message, response.status);
|
||||
}
|
||||
return payload as unknown as T;
|
||||
}
|
||||
|
||||
#getModel(requestedModel?: string): IVideoModel {
|
||||
const wanted = (requestedModel ?? '').trim().toLowerCase();
|
||||
return (
|
||||
MINIMAX_VIDEO_GENERATION_MODELS.find(
|
||||
(m) =>
|
||||
m.id === wanted ||
|
||||
m.puterId === wanted ||
|
||||
m.aliases?.includes(wanted),
|
||||
) ??
|
||||
MINIMAX_VIDEO_GENERATION_MODELS.find((m) => m.id === DEFAULT_MODEL)!
|
||||
);
|
||||
}
|
||||
|
||||
#resolution(candidate: unknown, model: IVideoModel): string {
|
||||
const tiers = model.dimensions!;
|
||||
if (typeof candidate === 'string') {
|
||||
const wanted = candidate.trim().toLowerCase();
|
||||
if (tiers.includes(wanted)) return wanted;
|
||||
}
|
||||
return tiers[0];
|
||||
}
|
||||
|
||||
/** An image as MiniMax takes it: a URL or a data URI. */
|
||||
#image(value: unknown, label: string): string | undefined {
|
||||
if (!present(value)) return undefined;
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw badRequest(
|
||||
`${label} must be an image URL, data URI or base64`,
|
||||
);
|
||||
}
|
||||
return toUrlOrDataUri(value);
|
||||
}
|
||||
|
||||
#references(value: unknown): string[] {
|
||||
if (!present(value)) return [];
|
||||
if (!Array.isArray(value)) {
|
||||
throw badRequest('reference_images must be an array');
|
||||
}
|
||||
return value.map((image) => this.#image(image, 'reference_images')!);
|
||||
}
|
||||
|
||||
#coerceSeconds(value: unknown): number | undefined {
|
||||
const numeric =
|
||||
typeof value === 'string' ? Number.parseInt(value, 10) : value;
|
||||
return typeof numeric === 'number' &&
|
||||
Number.isFinite(numeric) &&
|
||||
numeric > 0
|
||||
? Math.round(numeric)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Snap width/height to one of MiniMax's aspect ratios. */
|
||||
#aspectRatio(width?: number, height?: number): string | undefined {
|
||||
if (
|
||||
typeof width !== 'number' ||
|
||||
typeof height !== 'number' ||
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const gcd = (a: number, b: number): number =>
|
||||
b === 0 ? a : gcd(b, a % b);
|
||||
const w = Math.round(width);
|
||||
const h = Math.round(height);
|
||||
const d = gcd(w, h) || 1;
|
||||
const candidate = `${w / d}:${h / d}`;
|
||||
// Other ratios are left to the 16:9 default.
|
||||
return ASPECT_RATIOS.includes(candidate) ? candidate : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* 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 { IVideoModel } from '../../types.js';
|
||||
|
||||
/** MiniMax-specific model behavior the shared IVideoModel shape can't carry. */
|
||||
export interface MiniMaxVideoSpec {
|
||||
/** The model name as MiniMax spells it on the wire. */
|
||||
apiModel: string;
|
||||
/**
|
||||
* `v2`: the H3 task API, billed per second of output. `v1`: the Hailuo API,
|
||||
* billed per clip, whose result is fetched through a file id.
|
||||
*/
|
||||
api: 'v1' | 'v2';
|
||||
/**
|
||||
* Frames and reference images together. MiniMax charges per image above
|
||||
* this quota, so more are refused rather than billed.
|
||||
*/
|
||||
maxImages: number;
|
||||
/** Whether `reference_images` are taken (H3 reference-to-video). */
|
||||
references: boolean;
|
||||
firstFrameRequired?: boolean;
|
||||
lastFrame: boolean;
|
||||
/** Resolutions MiniMax renders only from a first frame. */
|
||||
imageOnlyResolutions?: string[];
|
||||
maxPromptChars: number;
|
||||
}
|
||||
|
||||
// Duration ladder for the driver's normalization: element 0 is the fallback
|
||||
// default, the rest enumerate the model's contiguous valid range.
|
||||
const seconds = (def: number, min: number, max: number): number[] => [
|
||||
def,
|
||||
...Array.from({ length: max - min + 1 }, (_, i) => min + i).filter(
|
||||
(s) => s !== def,
|
||||
),
|
||||
];
|
||||
|
||||
// Prices from https://platform.minimax.io/docs/guides/pricing-video. H3 models
|
||||
// are per second of output, keyed `per-second:<resolution>`; Hailuo models are
|
||||
// per clip, keyed `per-video:<resolution>:<seconds>`, and a key's absence means
|
||||
// the model doesn't offer that combination. `default-duration-per-video` is
|
||||
// the default clip at the default resolution, for cross-provider sorting and
|
||||
// display only.
|
||||
export const MINIMAX_VIDEO_GENERATION_MODELS: IVideoModel[] = [
|
||||
{
|
||||
id: 'minimax-h3',
|
||||
puterId: 'minimax:minimax/minimax-h3',
|
||||
name: 'MiniMax H3',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-second:768p': 8,
|
||||
'per-second:2k': 13,
|
||||
'default-duration-per-video': 40,
|
||||
},
|
||||
durationSeconds: seconds(5, 4, 15),
|
||||
dimensions: ['768p', '2k'],
|
||||
fps: [24],
|
||||
},
|
||||
{
|
||||
id: 'minimax-h3-highspeed',
|
||||
puterId: 'minimax:minimax/minimax-h3-highspeed',
|
||||
name: 'MiniMax H3 Highspeed',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-second:768p': 6,
|
||||
'default-duration-per-video': 30,
|
||||
},
|
||||
durationSeconds: seconds(5, 4, 15),
|
||||
dimensions: ['768p'],
|
||||
fps: [24],
|
||||
},
|
||||
{
|
||||
id: 'minimax-h3-lite',
|
||||
puterId: 'minimax:minimax/minimax-h3-lite',
|
||||
name: 'MiniMax H3 Lite',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-second:768p': 4,
|
||||
'per-second:480p': 2.5,
|
||||
'default-duration-per-video': 20,
|
||||
},
|
||||
durationSeconds: seconds(5, 4, 15),
|
||||
dimensions: ['768p', '480p'],
|
||||
fps: [24],
|
||||
},
|
||||
{
|
||||
id: 'minimax-h3-max',
|
||||
puterId: 'minimax:minimax/minimax-h3-max',
|
||||
name: 'MiniMax H3 Max',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-second:768p': 8,
|
||||
'per-second:480p': 5,
|
||||
'default-duration-per-video': 40,
|
||||
},
|
||||
durationSeconds: seconds(5, 5, 15),
|
||||
dimensions: ['768p', '480p'],
|
||||
fps: [24],
|
||||
},
|
||||
{
|
||||
id: 'minimax-hailuo-2.3',
|
||||
puterId: 'minimax:minimax/minimax-hailuo-2.3',
|
||||
name: 'MiniMax Hailuo 2.3',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-video:768p:6': 28,
|
||||
'per-video:768p:10': 56,
|
||||
'per-video:1080p:6': 49,
|
||||
'default-duration-per-video': 28,
|
||||
},
|
||||
durationSeconds: [6, 10],
|
||||
dimensions: ['768p', '1080p'],
|
||||
fps: [24],
|
||||
},
|
||||
{
|
||||
id: 'minimax-hailuo-2.3-fast',
|
||||
puterId: 'minimax:minimax/minimax-hailuo-2.3-fast',
|
||||
name: 'MiniMax Hailuo 2.3 Fast',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-video:768p:6': 19,
|
||||
'per-video:768p:10': 32,
|
||||
'per-video:1080p:6': 33,
|
||||
'default-duration-per-video': 19,
|
||||
},
|
||||
durationSeconds: [6, 10],
|
||||
dimensions: ['768p', '1080p'],
|
||||
fps: [24],
|
||||
},
|
||||
{
|
||||
id: 'minimax-hailuo-02',
|
||||
puterId: 'minimax:minimax/minimax-hailuo-02',
|
||||
name: 'MiniMax Hailuo 02',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-video:768p:6': 28,
|
||||
'per-video:768p:10': 56,
|
||||
'per-video:1080p:6': 49,
|
||||
'per-video:512p:6': 10,
|
||||
'per-video:512p:10': 15,
|
||||
'default-duration-per-video': 28,
|
||||
},
|
||||
durationSeconds: [6, 10],
|
||||
dimensions: ['768p', '1080p', '512p'],
|
||||
fps: [24],
|
||||
},
|
||||
];
|
||||
|
||||
// Capabilities per https://platform.minimax.io/docs/api-reference/video-generation-v2-create
|
||||
// (H3) and the text-, image- and frames-to-video pages under
|
||||
// https://platform.minimax.io/docs/api-reference/video-generation-t2v (Hailuo).
|
||||
export const MINIMAX_VIDEO_SPECS: Record<string, MiniMaxVideoSpec> = {
|
||||
'minimax-h3': {
|
||||
apiModel: 'MiniMax-H3',
|
||||
api: 'v2',
|
||||
maxImages: 5,
|
||||
references: true,
|
||||
lastFrame: true,
|
||||
maxPromptChars: 7000,
|
||||
},
|
||||
'minimax-h3-highspeed': {
|
||||
apiModel: 'MiniMax-H3-Highspeed',
|
||||
api: 'v2',
|
||||
maxImages: 5,
|
||||
references: true,
|
||||
lastFrame: true,
|
||||
maxPromptChars: 7000,
|
||||
},
|
||||
'minimax-h3-lite': {
|
||||
apiModel: 'MiniMax-H3-Lite',
|
||||
api: 'v2',
|
||||
maxImages: 5,
|
||||
references: true,
|
||||
lastFrame: true,
|
||||
maxPromptChars: 7000,
|
||||
},
|
||||
'minimax-h3-max': {
|
||||
apiModel: 'MiniMax-H3-Max',
|
||||
api: 'v2',
|
||||
maxImages: 2,
|
||||
references: true,
|
||||
lastFrame: true,
|
||||
maxPromptChars: 7000,
|
||||
},
|
||||
'minimax-hailuo-2.3': {
|
||||
apiModel: 'MiniMax-Hailuo-2.3',
|
||||
api: 'v1',
|
||||
maxImages: 1,
|
||||
references: false,
|
||||
lastFrame: false,
|
||||
maxPromptChars: 2000,
|
||||
},
|
||||
'minimax-hailuo-2.3-fast': {
|
||||
apiModel: 'MiniMax-Hailuo-2.3-Fast',
|
||||
api: 'v1',
|
||||
maxImages: 1,
|
||||
references: false,
|
||||
firstFrameRequired: true,
|
||||
lastFrame: false,
|
||||
maxPromptChars: 2000,
|
||||
},
|
||||
'minimax-hailuo-02': {
|
||||
apiModel: 'MiniMax-Hailuo-02',
|
||||
api: 'v1',
|
||||
maxImages: 2,
|
||||
references: false,
|
||||
lastFrame: true,
|
||||
imageOnlyResolutions: ['512p'],
|
||||
maxPromptChars: 2000,
|
||||
},
|
||||
};
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import { actorOwnsRow, requireContextActor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { subdomainLabelOf } from '../../core/http/middleware/hostRedirects.js';
|
||||
import { extensionStore } from '../../extensions.js';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
@@ -719,9 +721,10 @@ export class AppDriver extends PuterDriver {
|
||||
* loading from the GUI host would execute third-party code same-origin with
|
||||
* the desktop — a full sandbox escape. The API host is reserved for the
|
||||
* same reason, and the builtin sentinel host is rewritten by the GUI to
|
||||
* `<gui origin>/builtin/…` (see BUILTIN_APPS_HOST above). Host comparison
|
||||
* (rather than full origin) deliberately also catches scheme/port variants
|
||||
* of these hosts.
|
||||
* `<gui origin>/builtin/…` (see BUILTIN_APPS_HOST above). A subdomain an
|
||||
* extension has claimed is a system host too. Host comparison (rather than
|
||||
* full origin) deliberately also catches scheme/port variants of these
|
||||
* hosts.
|
||||
*/
|
||||
#assertIndexUrlHostAllowed(indexUrl) {
|
||||
let hostname;
|
||||
@@ -753,7 +756,13 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
if (reserved.has(hostname)) {
|
||||
const label = config.domain
|
||||
? subdomainLabelOf(hostname, config.domain)
|
||||
: undefined;
|
||||
if (
|
||||
reserved.has(hostname) ||
|
||||
(label !== undefined && extensionStore.claimedSubdomains.has(label))
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`index_url` cannot point at a Puter system host',
|
||||
|
||||
@@ -21,6 +21,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { extensionStore } from '../../extensions.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { decodeCursor } from '../../util/pagination.js';
|
||||
@@ -195,6 +196,105 @@ describe('AppDriver.create', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a claimed subdomain', () => {
|
||||
const claimed = 'claimed-app-host';
|
||||
|
||||
beforeAll(() => {
|
||||
extensionStore.claimedSubdomains.set(claimed, {
|
||||
authorize: () => true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
extensionStore.claimedSubdomains.delete(claimed);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['exactly', `https://${claimed}.puter.localhost/`],
|
||||
[
|
||||
'in uppercase',
|
||||
`https://${claimed.toUpperCase()}.PUTER.localhost/`,
|
||||
],
|
||||
['with a port', `http://${claimed}.puter.localhost:4100/app.html`],
|
||||
])(
|
||||
'rejects an index_url on it %s with 400',
|
||||
async (_label, index_url) => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('claimed-host'),
|
||||
title: 't',
|
||||
index_url,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: /system host/,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['exactly', `https://${claimed}.puter.localhost/`],
|
||||
[
|
||||
'in uppercase',
|
||||
`https://${claimed.toUpperCase()}.PUTER.localhost/`,
|
||||
],
|
||||
['with a port', `http://${claimed}.puter.localhost:4100/app.html`],
|
||||
])(
|
||||
'rejects updating an index_url onto it %s with 400',
|
||||
async (_label, index_url) => {
|
||||
const { actor } = await makeUser();
|
||||
const created = await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('claimed-host-upd'),
|
||||
title: 't',
|
||||
index_url: uniqueIndexUrl(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.update({
|
||||
uid: created.uid,
|
||||
object: { index_url },
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: /system host/,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an unclaimed sibling subdomain',
|
||||
'https://not-claimed-app-host.puter.localhost/',
|
||||
],
|
||||
[
|
||||
'a host nested under the claim',
|
||||
`https://x.${claimed}.puter.localhost/`,
|
||||
],
|
||||
])('allows an index_url on %s', async (_label, index_url) => {
|
||||
const { actor } = await makeUser();
|
||||
const created = await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('claimed-sibling'),
|
||||
title: 't',
|
||||
index_url,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(created.index_url).toBe(index_url);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a duplicate app name with 400', async () => {
|
||||
const a = await makeUser();
|
||||
const b = await makeUser();
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
* a test file.
|
||||
*/
|
||||
|
||||
import type { EventClient } from '../clients/event/EventClient.js';
|
||||
import { translateProviderError } from '../controllers/drivers/DriverController.js';
|
||||
import type { Actor } from '../core/actor.js';
|
||||
import { SYSTEM_ACTOR, makeActor } from '../core/actor.js';
|
||||
@@ -60,6 +61,23 @@ export const skipUnlessEnv = (name: string): boolean => !optionalEnv(name);
|
||||
*/
|
||||
export const INTEGRATION_TEST_TIMEOUT_MS = 90_000;
|
||||
|
||||
/** Runs `fn` with every AI driver's cost factor hooked to `factor`. */
|
||||
export const withAiCostFactorHook = async <T>(
|
||||
events: EventClient,
|
||||
factor: number,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const hook = (_key: string, event: { factor: number }) => {
|
||||
event.factor = factor;
|
||||
};
|
||||
events.on('ai.cost.factor.*', hook);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
events.off('ai.cost.factor.*', hook);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a no-op MeteringService stub. Real metering would write to DynamoDB /
|
||||
* Redis, which integration tests for AI providers don't care about — we just
|
||||
|
||||
@@ -99,7 +99,8 @@ describe('image downloads', () => {
|
||||
statusCode: 400,
|
||||
code: 'input_too_large',
|
||||
});
|
||||
expect(pulled).toBeLessThanOrEqual(4);
|
||||
// Readable.fromWeb reads ahead by a few chunks before the cap trips.
|
||||
expect(pulled).toBeLessThanOrEqual(6);
|
||||
});
|
||||
it('gives up on a slow host with a 400 rather than waiting it out', async () => {
|
||||
secureFetchMock.mockImplementationOnce(
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* WorkerDriver with a deploy backend configured.
|
||||
*
|
||||
@@ -47,7 +48,9 @@ import { PuterServer } from '../../server.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { resolveCallableMethods } from '../meta.js';
|
||||
import {
|
||||
INTERNAL_ADMIN_DESTROY,
|
||||
INTERNAL_ADMISSION_BYPASS,
|
||||
type WorkerDriver,
|
||||
} from './WorkerDriver.js';
|
||||
@@ -60,8 +63,12 @@ let server: PuterServer;
|
||||
let target: WorkerDriver;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
|
||||
const edgeResponse = (body: unknown) =>
|
||||
({ json: async () => body }) as unknown as Response;
|
||||
const edgeResponse = (body: unknown, status = 200) =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as unknown as Response;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
@@ -159,6 +166,30 @@ const deleteCalls = () =>
|
||||
(call) => (call[1] as RequestInit | undefined)?.method === 'DELETE',
|
||||
);
|
||||
|
||||
const deployedToken = () => {
|
||||
const [, init] = putCalls().at(-1)!;
|
||||
const form = (init as RequestInit).body as FormData;
|
||||
const metadata = JSON.parse(form.get('metadata') as string);
|
||||
return metadata.bindings.find(
|
||||
(b: { name: string }) => b.name === 'puter_auth',
|
||||
).text as string;
|
||||
};
|
||||
|
||||
/** A fresh user with one deployed worker and the token it was deployed with. */
|
||||
const deployWorker = async (label: string) => {
|
||||
const { user, actor } = await makeUser();
|
||||
const path = `/${user.username}/${label}.js`;
|
||||
const entry = await writeSource(actor, user.id, path, 'src');
|
||||
const name = `${label}-${user.username}`;
|
||||
await inCtx(actor, () =>
|
||||
target.create({ appId: '', workerName: name, filePath: path }),
|
||||
);
|
||||
return { user, actor, entry, name, token: deployedToken() };
|
||||
};
|
||||
|
||||
const workerSession = (userId: number, workerName: string) =>
|
||||
server.stores.session.getWorker(userId, { appUid: null, workerName });
|
||||
|
||||
// -- create ----------------------------------------------------------
|
||||
|
||||
describe('WorkerDriver.create with a configured deploy backend', () => {
|
||||
@@ -596,17 +627,220 @@ describe('WorkerDriver.destroy with a configured deploy backend', () => {
|
||||
target.destroy({ workerName: name }),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
errors: [],
|
||||
messages: [],
|
||||
result: { id: name },
|
||||
});
|
||||
const [url, init] = deleteCalls()[0]!;
|
||||
expect(url).toBe(`${SCRIPTS_BASE}/${name}/`);
|
||||
expect((init as RequestInit).headers).toEqual({
|
||||
Authorization: `Bearer ${AUTH_KEY}`,
|
||||
});
|
||||
expect((init as RequestInit).signal).toBeInstanceOf(AbortSignal);
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('reports a refused edge delete as 502 and keeps the row and the worker session', async () => {
|
||||
const { user, actor, name, token } = await deployWorker('refused');
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
edgeResponse(
|
||||
{
|
||||
success: false,
|
||||
errors: [{ code: 10000, message: 'Internal error' }],
|
||||
},
|
||||
500,
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
inCtx(actor, () => target.destroy({ workerName: name })),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'worker_takedown_failed',
|
||||
message:
|
||||
'Cloudflare refused to remove the script; nothing was deleted.',
|
||||
fields: { status: 500 },
|
||||
});
|
||||
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(await workerSession(user.id, name)).toBeTruthy();
|
||||
expect(
|
||||
(await server.services.auth.authenticate(token)).actor,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the row when the edge answers 200 with success:false', async () => {
|
||||
const { actor, name } = await deployWorker('softfail');
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
edgeResponse({ success: false, errors: [{ code: 10001 }] }),
|
||||
);
|
||||
await expect(
|
||||
inCtx(actor, () => target.destroy({ workerName: name })),
|
||||
).rejects.toMatchObject({ statusCode: 502 });
|
||||
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reports an edge delete that times out as 504 and keeps the row', async () => {
|
||||
const { actor, name } = await deployWorker('slow');
|
||||
|
||||
fetchSpy.mockRejectedValueOnce(
|
||||
new DOMException('The operation was aborted', 'TimeoutError'),
|
||||
);
|
||||
await expect(
|
||||
inCtx(actor, () => target.destroy({ workerName: name })),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 504,
|
||||
legacyCode: 'worker_takedown_timeout',
|
||||
});
|
||||
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reports a transport failure as 504 and keeps the row', async () => {
|
||||
const { actor, name } = await deployWorker('unreachable');
|
||||
|
||||
fetchSpy.mockRejectedValueOnce(new TypeError('fetch failed'));
|
||||
await expect(
|
||||
inCtx(actor, () => target.destroy({ workerName: name })),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 504,
|
||||
legacyCode: 'worker_takedown_timeout',
|
||||
});
|
||||
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('lets any other failure before the edge answers surface as itself', async () => {
|
||||
const { actor, name } = await deployWorker('ownerr');
|
||||
|
||||
fetchSpy.mockRejectedValueOnce(new Error('config unavailable'));
|
||||
const err = (await inCtx(actor, () =>
|
||||
target.destroy({ workerName: name }),
|
||||
).catch((e: unknown) => e)) as Error & { statusCode?: number };
|
||||
expect(err.message).toBe('config unavailable');
|
||||
expect(err.statusCode).toBeUndefined();
|
||||
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('tears down in order: script, then sessions, then row', async () => {
|
||||
const { user, actor, name } = await deployWorker('order');
|
||||
const store = server.stores.subdomain;
|
||||
let seenAtRowDelete: { edgeDeletes: number; sessionGone: boolean } = {
|
||||
edgeDeletes: -1,
|
||||
sessionGone: false,
|
||||
};
|
||||
const deleteSpy = vi
|
||||
.spyOn(store, 'deleteByUuid')
|
||||
.mockImplementation(async (...args) => {
|
||||
seenAtRowDelete = {
|
||||
edgeDeletes: deleteCalls().length,
|
||||
sessionGone: !(await workerSession(user.id, name)),
|
||||
};
|
||||
deleteSpy.mockRestore();
|
||||
return store.deleteByUuid(...args);
|
||||
});
|
||||
|
||||
await inCtx(actor, () => target.destroy({ workerName: name }));
|
||||
|
||||
expect(seenAtRowDelete).toEqual({ edgeDeletes: 1, sessionGone: true });
|
||||
});
|
||||
|
||||
it('keeps the row when the sessions cannot be dropped, and a retry finishes the job', async () => {
|
||||
const { actor, name } = await deployWorker('retry');
|
||||
vi.spyOn(
|
||||
server.services.auth,
|
||||
'deleteWorkerSessionsByName',
|
||||
).mockRejectedValueOnce(new Error('session store unavailable'));
|
||||
|
||||
await expect(
|
||||
inCtx(actor, () => target.destroy({ workerName: name })),
|
||||
).rejects.toThrow('session store unavailable');
|
||||
expect(deleteCalls()).toHaveLength(1);
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
const retried = await inCtx(actor, () =>
|
||||
target.destroy({ workerName: name }),
|
||||
);
|
||||
expect(retried).toEqual({
|
||||
success: true,
|
||||
errors: [],
|
||||
messages: [],
|
||||
result: { id: name },
|
||||
});
|
||||
expect(deleteCalls()).toHaveLength(2);
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('treats a script the edge no longer has (code 10007) as taken down', async () => {
|
||||
const { user, actor, name } = await deployWorker('gone10007');
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
edgeResponse(
|
||||
{
|
||||
success: false,
|
||||
errors: [
|
||||
{
|
||||
code: 10007,
|
||||
message: 'workers.api.error.script_not_found',
|
||||
},
|
||||
],
|
||||
},
|
||||
404,
|
||||
),
|
||||
);
|
||||
const result = await inCtx(actor, () =>
|
||||
target.destroy({ workerName: name }),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
errors: [],
|
||||
messages: [],
|
||||
result: { id: name },
|
||||
});
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(await workerSession(user.id, name)).toBeNull();
|
||||
});
|
||||
|
||||
it('lowercases the requested name before looking the worker up', async () => {
|
||||
@@ -630,6 +864,319 @@ describe('WorkerDriver.destroy with a configured deploy backend', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- operator teardown ------------------------------------------------
|
||||
|
||||
describe('WorkerDriver operator teardown (INTERNAL_ADMIN_DESTROY)', () => {
|
||||
const rowOf = (name: string) =>
|
||||
server.stores.subdomain.getBySubdomain(`workers.puter.${name}`, {
|
||||
primary: true,
|
||||
});
|
||||
|
||||
/** Collects every `subdomain.delete` announced while `fn` runs. */
|
||||
const announced = async <T>(fn: () => Promise<T>) => {
|
||||
const seen: unknown[] = [];
|
||||
const handler = (_key: string, data: unknown) => {
|
||||
seen.push(data);
|
||||
};
|
||||
server.clients.event.on('subdomain.delete', handler as never);
|
||||
try {
|
||||
return { result: await fn(), seen };
|
||||
} finally {
|
||||
server.clients.event.off('subdomain.delete', handler as never);
|
||||
}
|
||||
};
|
||||
|
||||
it('takes the script down before the row, drops the session, and announces the delete once', async () => {
|
||||
const { user, name, token } = await deployWorker('op');
|
||||
const row = (await rowOf(name))!;
|
||||
const store = server.stores.subdomain;
|
||||
let seenAtRowDelete = { edgeDeletes: -1, sessionGone: false };
|
||||
const deleteSpy = vi
|
||||
.spyOn(store, 'deleteByUuid')
|
||||
.mockImplementation(async (...args) => {
|
||||
seenAtRowDelete = {
|
||||
edgeDeletes: deleteCalls().length,
|
||||
sessionGone: !(await workerSession(user.id, name)),
|
||||
};
|
||||
deleteSpy.mockRestore();
|
||||
return store.deleteByUuid(...args);
|
||||
});
|
||||
|
||||
const { result, seen } = await announced(() =>
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: row.uuid }),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
subdomain: `workers.puter.${name}`,
|
||||
userId: user.id,
|
||||
workerName: name,
|
||||
});
|
||||
const [url] = deleteCalls()[0]!;
|
||||
expect(url).toBe(`${SCRIPTS_BASE}/${name}/`);
|
||||
expect(seenAtRowDelete).toEqual({ edgeDeletes: 1, sessionGone: true });
|
||||
expect(await rowOf(name)).toBeNull();
|
||||
expect(await workerSession(user.id, name)).toBeNull();
|
||||
expect(await server.services.auth.authenticate(token)).toEqual({
|
||||
invalid: true,
|
||||
});
|
||||
expect(seen).toEqual([
|
||||
{ subdomain: `workers.puter.${name}`, uid: row.uuid },
|
||||
]);
|
||||
|
||||
// The driver's own listener hears this and leaves the script alone.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(deleteCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports a refused edge delete as 502 and keeps the row and session', async () => {
|
||||
const { user, name, token } = await deployWorker('oprefused');
|
||||
const row = (await rowOf(name))!;
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
edgeResponse(
|
||||
{
|
||||
success: false,
|
||||
errors: [{ code: 10000, message: 'edge-only detail' }],
|
||||
},
|
||||
500,
|
||||
),
|
||||
);
|
||||
const { seen } = await announced(async () => {
|
||||
const err = (await target[INTERNAL_ADMIN_DESTROY]({
|
||||
uuid: row.uuid,
|
||||
}).catch((e: unknown) => e)) as {
|
||||
statusCode: number;
|
||||
legacyCode: string;
|
||||
message: string;
|
||||
fields: unknown;
|
||||
};
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'worker_takedown_failed',
|
||||
message:
|
||||
'Cloudflare refused to remove the script; nothing was deleted.',
|
||||
fields: { status: 500 },
|
||||
});
|
||||
// Cloudflare's own text stays out of what the caller sees.
|
||||
expect(JSON.stringify([err.message, err.fields])).not.toContain(
|
||||
'edge-only detail',
|
||||
);
|
||||
});
|
||||
|
||||
expect(await rowOf(name)).toMatchObject({ uuid: row.uuid });
|
||||
expect(await workerSession(user.id, name)).toBeTruthy();
|
||||
expect(
|
||||
(await server.services.auth.authenticate(token)).actor,
|
||||
).toBeTruthy();
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the row when the edge answers 200 with success:false', async () => {
|
||||
const { name } = await deployWorker('opsoft');
|
||||
const row = (await rowOf(name))!;
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
edgeResponse({ success: false, errors: [] }),
|
||||
);
|
||||
await expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: row.uuid }),
|
||||
).rejects.toMatchObject({ statusCode: 502 });
|
||||
|
||||
expect(await rowOf(name)).toMatchObject({ uuid: row.uuid });
|
||||
});
|
||||
|
||||
it('treats script-not-found (code 10007) as taken down', async () => {
|
||||
const { name } = await deployWorker('op10007');
|
||||
const row = (await rowOf(name))!;
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
edgeResponse(
|
||||
{
|
||||
success: false,
|
||||
errors: [
|
||||
{
|
||||
code: 10007,
|
||||
message: 'workers.api.error.script_not_found',
|
||||
},
|
||||
],
|
||||
},
|
||||
404,
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: row.uuid }),
|
||||
).resolves.toMatchObject({ workerName: name });
|
||||
|
||||
expect(await rowOf(name)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a bare 404 with no usable body as a refusal and keeps the row', async () => {
|
||||
const { name } = await deployWorker('op404');
|
||||
const row = (await rowOf(name))!;
|
||||
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => {
|
||||
throw new SyntaxError('not json');
|
||||
},
|
||||
} as unknown as Response);
|
||||
await expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: row.uuid }),
|
||||
).rejects.toMatchObject({ statusCode: 502, fields: { status: 404 } });
|
||||
|
||||
expect(await rowOf(name)).toMatchObject({ uuid: row.uuid });
|
||||
});
|
||||
|
||||
it('treats a 404 that could not be routed (code 7003) as a refusal and keeps the row', async () => {
|
||||
const { name } = await deployWorker('op7003');
|
||||
const row = (await rowOf(name))!;
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
edgeResponse(
|
||||
{
|
||||
success: false,
|
||||
errors: [
|
||||
{
|
||||
code: 7003,
|
||||
message: 'Could not route to /accounts/x/workers',
|
||||
},
|
||||
],
|
||||
},
|
||||
404,
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: row.uuid }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'worker_takedown_failed',
|
||||
});
|
||||
|
||||
expect(await rowOf(name)).toMatchObject({ uuid: row.uuid });
|
||||
});
|
||||
|
||||
it('reports a timed-out edge delete as 504 with no edge text, and keeps the row', async () => {
|
||||
const { name } = await deployWorker('opslow');
|
||||
const row = (await rowOf(name))!;
|
||||
|
||||
fetchSpy.mockRejectedValueOnce(
|
||||
new DOMException('edge-only timeout detail', 'TimeoutError'),
|
||||
);
|
||||
const err = (await target[INTERNAL_ADMIN_DESTROY]({
|
||||
uuid: row.uuid,
|
||||
}).catch((e: unknown) => e)) as {
|
||||
message: string;
|
||||
fields: unknown;
|
||||
};
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 504,
|
||||
legacyCode: 'worker_takedown_timeout',
|
||||
});
|
||||
expect(JSON.stringify([err.message, err.fields ?? null])).not.toContain(
|
||||
'edge-only',
|
||||
);
|
||||
|
||||
expect(await rowOf(name)).toMatchObject({ uuid: row.uuid });
|
||||
});
|
||||
|
||||
it('keeps the row when the sessions cannot be dropped, and a retry finishes the job', async () => {
|
||||
const { name } = await deployWorker('opretry');
|
||||
const row = (await rowOf(name))!;
|
||||
vi.spyOn(
|
||||
server.services.auth,
|
||||
'deleteWorkerSessionsByName',
|
||||
).mockRejectedValueOnce(new Error('session store unavailable'));
|
||||
|
||||
const { seen } = await announced(async () =>
|
||||
expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: row.uuid }),
|
||||
).rejects.toThrow('session store unavailable'),
|
||||
);
|
||||
expect(deleteCalls()).toHaveLength(1);
|
||||
expect(await rowOf(name)).toMatchObject({ uuid: row.uuid });
|
||||
expect(seen).toEqual([]);
|
||||
|
||||
await target[INTERNAL_ADMIN_DESTROY]({ uuid: row.uuid });
|
||||
expect(deleteCalls()).toHaveLength(2);
|
||||
expect(await rowOf(name)).toBeNull();
|
||||
});
|
||||
|
||||
it('skips only its own announcement: a re-created worker of the same name is still taken down', async () => {
|
||||
const { user, actor, name } = await deployWorker('opsame');
|
||||
const first = (await rowOf(name))!;
|
||||
await target[INTERNAL_ADMIN_DESTROY]({ uuid: first.uuid });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(deleteCalls()).toHaveLength(1);
|
||||
|
||||
// Same name, new row: a delete announced for it is not the one above.
|
||||
await inCtx(actor, () =>
|
||||
target.create({
|
||||
appId: '',
|
||||
workerName: name,
|
||||
filePath: `/${user.username}/opsame.js`,
|
||||
}),
|
||||
);
|
||||
const second = (await rowOf(name))!;
|
||||
expect(second.uuid).not.toBe(first.uuid);
|
||||
await server.stores.subdomain.deleteByUuid(second.uuid);
|
||||
server.clients.event.emit(
|
||||
'subdomain.delete',
|
||||
{ subdomain: `workers.puter.${name}`, uid: second.uuid },
|
||||
{},
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
async () => deleteCalls().length === 2,
|
||||
'edge delete for the re-created worker',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 for a row that is not a worker, and leaves it alone', async () => {
|
||||
const { user } = await makeUser();
|
||||
const site = await server.stores.subdomain.create({
|
||||
userId: user.id,
|
||||
subdomain: `site-${user.username}`,
|
||||
rootDirId: null,
|
||||
associatedAppId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: site.uuid }),
|
||||
).rejects.toMatchObject({ statusCode: 404, legacyCode: 'not_found' });
|
||||
|
||||
expect(deleteCalls()).toEqual([]);
|
||||
expect(
|
||||
await server.stores.subdomain.getByUuid(site.uuid, {
|
||||
primary: true,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown or missing uuid', async () => {
|
||||
await expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: uuidv4() }),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
await expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({} as { uuid: string }),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
expect(deleteCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('is not on the /drivers/call surface', () => {
|
||||
const callable = resolveCallableMethods(target);
|
||||
expect([...callable].sort()).toEqual([
|
||||
'create',
|
||||
'destroy',
|
||||
'getFilePaths',
|
||||
'getLoggingUrl',
|
||||
]);
|
||||
expect(callable.has(String(INTERNAL_ADMIN_DESTROY.description))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -- worker rows deleted elsewhere ------------------------------------
|
||||
|
||||
describe('WorkerDriver on subdomain.delete', () => {
|
||||
@@ -1027,29 +1574,6 @@ describe('WorkerDriver hot reload', () => {
|
||||
// -- worker sessions --------------------------------------------------
|
||||
|
||||
describe('WorkerDriver keeps a worker`s token tied to the worker', () => {
|
||||
const deployedToken = () => {
|
||||
const [, init] = putCalls().at(-1)!;
|
||||
const form = (init as RequestInit).body as FormData;
|
||||
const metadata = JSON.parse(form.get('metadata') as string);
|
||||
return metadata.bindings.find(
|
||||
(b: { name: string }) => b.name === 'puter_auth',
|
||||
).text as string;
|
||||
};
|
||||
|
||||
const deployWorker = async (label: string) => {
|
||||
const { user, actor } = await makeUser();
|
||||
const path = `/${user.username}/${label}.js`;
|
||||
const entry = await writeSource(actor, user.id, path, 'src');
|
||||
const name = `${label}-${user.username}`;
|
||||
await inCtx(actor, () =>
|
||||
target.create({ appId: '', workerName: name, filePath: path }),
|
||||
);
|
||||
return { user, actor, entry, name, token: deployedToken() };
|
||||
};
|
||||
|
||||
const workerSession = (userId: number, workerName: string) =>
|
||||
server.stores.session.getWorker(userId, { appUid: null, workerName });
|
||||
|
||||
it('binds the deployed token to the worker row, and a redeploy keeps it', async () => {
|
||||
const { user, actor, name, token } = await deployWorker('bind');
|
||||
const row = await server.stores.subdomain.getBySubdomain(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// This test checks everything in WorkerDriver up to the cloudflare level.
|
||||
// This test checks everything in WorkerDriver up to the cloudflare level.
|
||||
// Full deployments are not tested as this would require Cloudflare Workerd,
|
||||
// however the interactions with the Puter API are
|
||||
import {
|
||||
@@ -13,10 +13,18 @@ import {
|
||||
import { Actor, makeActor as resolveActor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import {
|
||||
setupPuterTestEnv,
|
||||
setupTestServer,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import { decodeCursor } from '../../util/pagination.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { INTERNAL_ADMISSION_BYPASS } from './WorkerDriver.js';
|
||||
import { resolveCallableMethods } from '../meta.js';
|
||||
import {
|
||||
INTERNAL_ADMIN_DESTROY,
|
||||
INTERNAL_ADMISSION_BYPASS,
|
||||
} from './WorkerDriver.js';
|
||||
import type { WorkerDriver } from './WorkerDriver.js';
|
||||
|
||||
describe('WorkerDriver', () => {
|
||||
@@ -252,7 +260,10 @@ describe('WorkerDriver', () => {
|
||||
it('lets an app bind a worker to an app it created', async () => {
|
||||
const { generated, builderActor } = await seedApps();
|
||||
await expect(
|
||||
inCtx(() => deploy(generated.uid, 'wk-sandboxed'), builderActor),
|
||||
inCtx(
|
||||
() => deploy(generated.uid, 'wk-sandboxed'),
|
||||
builderActor,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 503 });
|
||||
});
|
||||
|
||||
@@ -266,14 +277,20 @@ describe('WorkerDriver', () => {
|
||||
it('rejects an app binding a worker to an app it did not create', async () => {
|
||||
const { unrelated, builderActor } = await seedApps();
|
||||
await expect(
|
||||
inCtx(() => deploy(unrelated.uid, 'wk-unrelated'), builderActor),
|
||||
inCtx(
|
||||
() => deploy(unrelated.uid, 'wk-unrelated'),
|
||||
builderActor,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('rejects an app binding a worker to an unknown app', async () => {
|
||||
const { builderActor } = await seedApps();
|
||||
await expect(
|
||||
inCtx(() => deploy('app-does-not-exist', 'wk-ghost'), builderActor),
|
||||
inCtx(
|
||||
() => deploy('app-does-not-exist', 'wk-ghost'),
|
||||
builderActor,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
@@ -906,7 +923,9 @@ describe('WorkerDriver', () => {
|
||||
try {
|
||||
for (const body of forgeries) {
|
||||
const forged = JSON.parse(body) as Record<string, unknown>;
|
||||
expect(Object.getOwnPropertySymbols(forged)).toHaveLength(0);
|
||||
expect(Object.getOwnPropertySymbols(forged)).toHaveLength(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor: unverified() }, () =>
|
||||
strictDriver.create({
|
||||
@@ -927,3 +946,139 @@ describe('WorkerDriver', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Boots local workerd mode, which sticks for the rest of this file: runs last.
|
||||
describe('WorkerDriver operator teardown on local workerd', () => {
|
||||
let env: PuterTestEnv;
|
||||
let driver: WorkerDriver;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
workers: { localServer: true },
|
||||
} as never);
|
||||
driver = env.server.drivers.workers as unknown as WorkerDriver;
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const seedWorker = async (label: string) => {
|
||||
const owner = await env.server.stores.user.create({
|
||||
username: `wklocal-${label}`,
|
||||
uuid: `wklocal-uuid-${label}`,
|
||||
password: null,
|
||||
email: null,
|
||||
});
|
||||
const row = await env.server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: `workers.puter.${label}`,
|
||||
rootDirId: null,
|
||||
associatedAppId: null,
|
||||
});
|
||||
return { owner, row };
|
||||
};
|
||||
|
||||
/** A worker deployed for real through the driver, so Miniflare holds it. */
|
||||
const deployLocal = async (label: string) => {
|
||||
const owner = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
const ownerActor = resolveActor({
|
||||
user: {
|
||||
uuid: owner!.uuid,
|
||||
id: owner!.id,
|
||||
username: owner!.username,
|
||||
email: `${owner!.username}@test.com`,
|
||||
email_confirmed: true,
|
||||
},
|
||||
});
|
||||
const path = `/${owner!.username}/${label}.js`;
|
||||
const source = `router.get('/', () => new Response('${label}'));`;
|
||||
await runWithContext({ actor: ownerActor }, () =>
|
||||
env.server.services.fs.write(owner!.id, {
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: Buffer.byteLength(source),
|
||||
contentType: 'application/javascript',
|
||||
overwrite: true,
|
||||
},
|
||||
fileContent: Buffer.from(source),
|
||||
}),
|
||||
);
|
||||
const deployed = (await runWithContext({ actor: ownerActor }, () =>
|
||||
driver.create({ appId: '', workerName: label, filePath: path }),
|
||||
)) as { success?: boolean };
|
||||
expect(deployed.success).toBe(true);
|
||||
const row = await env.server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${label}`,
|
||||
{ primary: true },
|
||||
);
|
||||
return { owner: owner!, row: row! };
|
||||
};
|
||||
|
||||
it('takes the local script down, drops the row, and reports the owner', async () => {
|
||||
const { owner, row } = await deployLocal('torn');
|
||||
const local = env.server.services.localworkerservice;
|
||||
const deleteLocal = vi.spyOn(local, 'cfDeleteLocal');
|
||||
const served = await local.cfCallLocal(
|
||||
'torn',
|
||||
new Request('http://worker.localhost/'),
|
||||
);
|
||||
expect(await served.text()).toBe('torn');
|
||||
|
||||
const result = await driver[INTERNAL_ADMIN_DESTROY]({
|
||||
uuid: row.uuid,
|
||||
});
|
||||
|
||||
expect(deleteLocal).toHaveBeenCalledWith('torn');
|
||||
expect(result).toEqual({
|
||||
subdomain: 'workers.puter.torn',
|
||||
userId: owner.id,
|
||||
workerName: 'torn',
|
||||
});
|
||||
expect(
|
||||
await env.server.stores.subdomain.getByUuid(row.uuid, {
|
||||
primary: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
// The Miniflare instance is disposed with the row: nothing answers now.
|
||||
const after = await local.cfCallLocal(
|
||||
'torn',
|
||||
new Request('http://worker.localhost/'),
|
||||
);
|
||||
expect(after.status).toBe(404);
|
||||
}, 60_000);
|
||||
|
||||
it('is absent from the callable surface and unreachable over /drivers/call', async () => {
|
||||
const { row } = await seedWorker('kept');
|
||||
expect(resolveCallableMethods(driver).has('destroy')).toBe(true);
|
||||
|
||||
for (const method of [
|
||||
String(INTERNAL_ADMIN_DESTROY.description),
|
||||
'INTERNAL_ADMIN_DESTROY',
|
||||
`Symbol(${INTERNAL_ADMIN_DESTROY.description})`,
|
||||
]) {
|
||||
expect(resolveCallableMethods(driver).has(method)).toBe(false);
|
||||
const res = await fetch(new URL('/drivers/call', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${env.users.user.token}`,
|
||||
Origin: env.apiOrigin,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
interface: 'workers',
|
||||
method,
|
||||
args: { uuid: row.uuid },
|
||||
}),
|
||||
});
|
||||
expect(res.status, method).toBe(404);
|
||||
}
|
||||
expect(
|
||||
await env.server.stores.subdomain.getByUuid(row.uuid, {
|
||||
primary: true,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from '../../services/metering/consts.js';
|
||||
import type { DriverConcurrentConfig, DriverRateLimitConfig } from '../meta.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { BoundedTtlMap } from '../../util/boundedTtlMap.js';
|
||||
import { awaitLater } from '../../util/concurrency.js';
|
||||
import { isUniqueViolation } from '../../util/dbError.js';
|
||||
import { loadFileInput } from '../util/fileInput.js';
|
||||
@@ -92,6 +93,58 @@ export const INTERNAL_ADMISSION_BYPASS = Symbol(
|
||||
*/
|
||||
export const INTERNAL_DEPLOY_TARGET = Symbol('workers.internalDeployTarget');
|
||||
|
||||
/** Operator teardown key; a symbol keeps it off the `/drivers/call` surface. */
|
||||
export const INTERNAL_ADMIN_DESTROY = Symbol('workers.internalAdminDestroy');
|
||||
|
||||
/** What the operator teardown reports back, for the caller's audit trail. */
|
||||
export interface AdminDestroyResult {
|
||||
subdomain: string;
|
||||
userId: number;
|
||||
workerName: string;
|
||||
}
|
||||
|
||||
/** The edge refused a script delete: neither success nor "no such script". */
|
||||
export class WorkerTakedownRefusedError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly detail: string | undefined,
|
||||
) {
|
||||
super(
|
||||
`Cloudflare refused the script delete (status ${status})${
|
||||
detail ? `: ${detail}` : ''
|
||||
}`,
|
||||
);
|
||||
this.name = 'WorkerTakedownRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
const CF_DELETE_TIMEOUT_MS = 15_000;
|
||||
// Cloudflare Workers API code 10007: Worker or workers.dev subdomain not found.
|
||||
const CF_SCRIPT_NOT_FOUND_CODE = 10007;
|
||||
|
||||
// What `fetch` rejects with when the edge never answered: timeout, transport.
|
||||
const isEdgeTransportError = (err: unknown): boolean =>
|
||||
err instanceof TypeError ||
|
||||
(err instanceof Error &&
|
||||
(err.name === 'TimeoutError' || err.name === 'AbortError'));
|
||||
|
||||
const withDeadline = <T>(promise: Promise<T>, ms: number): Promise<T> => {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const deadline = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new DOMException(
|
||||
'The operation was aborted due to timeout',
|
||||
'TimeoutError',
|
||||
),
|
||||
),
|
||||
ms,
|
||||
);
|
||||
});
|
||||
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
|
||||
};
|
||||
|
||||
export interface InternalDeployTarget {
|
||||
/** Script name to deploy under, in place of `workerName`. */
|
||||
scriptName: string;
|
||||
@@ -289,6 +342,11 @@ export class WorkerDriver extends PuterDriver {
|
||||
|
||||
#cfBaseUrl = '';
|
||||
#hotReloadSubscribed = false;
|
||||
// Row uids this process tore down itself; its own announcement skips them.
|
||||
#tornDown = new BoundedTtlMap<string, true>({
|
||||
maxEntries: 1000,
|
||||
ttlMs: 30_000,
|
||||
});
|
||||
|
||||
static currentPreambleVersion(): string | null {
|
||||
return preambleVersion;
|
||||
@@ -568,6 +626,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
return cfResult;
|
||||
}
|
||||
|
||||
/** Tear down the caller's worker; a refused edge delete is a 502. */
|
||||
async destroy(args: Record<string, unknown>): Promise<unknown> {
|
||||
const actor = requireContextUserActor();
|
||||
assertActorEmailVerified(actor, this.config);
|
||||
@@ -595,12 +654,60 @@ export class WorkerDriver extends PuterDriver {
|
||||
|
||||
this.#requireCfConfig();
|
||||
|
||||
const cfResult = await this.#cfDelete(workerName);
|
||||
await this.stores.subdomain.deleteByUuid(row.uuid, {
|
||||
userId: actor.user.id,
|
||||
});
|
||||
await this.#teardown(row, { userId: actor.user.id });
|
||||
// Stable regardless of the edge body: puter.js needs `result` truthy.
|
||||
return {
|
||||
success: true,
|
||||
errors: [],
|
||||
messages: [],
|
||||
result: { id: workerName },
|
||||
};
|
||||
}
|
||||
|
||||
/** Operator teardown by row uuid: no actor, no ownership check. */
|
||||
async [INTERNAL_ADMIN_DESTROY](input: {
|
||||
uuid: string;
|
||||
}): Promise<AdminDestroyResult> {
|
||||
const uuid = String(input?.uuid ?? '');
|
||||
const row = uuid
|
||||
? await this.stores.subdomain.getByUuid(uuid, { primary: true })
|
||||
: null;
|
||||
const subdomain = String(row?.subdomain ?? '');
|
||||
if (!row || !subdomain.startsWith(WORKER_SUBDOMAIN_PREFIX))
|
||||
throw new HttpError(404, 'Worker not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
|
||||
this.#requireCfConfig();
|
||||
|
||||
await this.#teardown(row);
|
||||
return {
|
||||
subdomain,
|
||||
userId: Number(row.user_id),
|
||||
workerName: subdomain.slice(WORKER_SUBDOMAIN_PREFIX.length),
|
||||
};
|
||||
}
|
||||
|
||||
/** Script, sessions, then row: a failed step leaves the row for a retry. */
|
||||
async #teardown(
|
||||
row: SubdomainRow,
|
||||
{ userId }: { userId?: number } = {},
|
||||
): Promise<void> {
|
||||
const subdomain = String(row.subdomain);
|
||||
const workerName = subdomain.slice(WORKER_SUBDOMAIN_PREFIX.length);
|
||||
await this.#takeDownScript(workerName);
|
||||
await this.services.auth.deleteWorkerSessionsByName(workerName);
|
||||
return cfResult;
|
||||
await this.stores.subdomain.deleteByUuid(row.uuid, { userId });
|
||||
this.#tornDown.set(row.uuid, true);
|
||||
try {
|
||||
this.clients.event.emit(
|
||||
'subdomain.delete',
|
||||
{ subdomain, uid: row.uuid },
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
// Non-critical.
|
||||
}
|
||||
}
|
||||
|
||||
async getFilePaths(args: Record<string, unknown>): Promise<unknown> {
|
||||
@@ -858,16 +965,70 @@ export class WorkerDriver extends PuterDriver {
|
||||
return { success: false, errors, url: null };
|
||||
}
|
||||
|
||||
/** Edge delete: success or code 10007 resolves, anything else refuses. */
|
||||
async #cfDelete(workerName: string): Promise<Record<string, unknown>> {
|
||||
if (USE_LOCAL_WORKERD) {
|
||||
return this.services.localworkerservice.cfDeleteLocal(workerName);
|
||||
return withDeadline(
|
||||
this.services.localworkerservice.cfDeleteLocal(workerName),
|
||||
CF_DELETE_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
const cfg = this.#workerConfig();
|
||||
const res = await fetch(`${this.#cfBaseUrl}/scripts/${workerName}/`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${cfg.XAUTHKEY}` },
|
||||
signal: AbortSignal.timeout(CF_DELETE_TIMEOUT_MS),
|
||||
});
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
let json: {
|
||||
success?: unknown;
|
||||
errors?: Array<{ code?: unknown; message?: unknown }>;
|
||||
} = {};
|
||||
try {
|
||||
json = (await res.json()) ?? {};
|
||||
} catch {
|
||||
// No usable body; the status decides.
|
||||
}
|
||||
if (res.ok && json.success !== false) return json;
|
||||
const errors = Array.isArray(json.errors) ? json.errors : [];
|
||||
if (errors.some((e) => Number(e?.code) === CF_SCRIPT_NOT_FOUND_CODE))
|
||||
return { success: true, errors: [], result: null };
|
||||
const first = errors[0]?.message;
|
||||
throw new WorkerTakedownRefusedError(
|
||||
res.status,
|
||||
typeof first === 'string' ? first : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Refusal is 502, timeout or transport 504; edge text is only logged. */
|
||||
async #takeDownScript(workerName: string): Promise<void> {
|
||||
try {
|
||||
await this.#cfDelete(workerName);
|
||||
} catch (err) {
|
||||
if (err instanceof WorkerTakedownRefusedError) {
|
||||
console.warn(
|
||||
`[workers] edge refused delete of ${workerName}: ${err.message}`,
|
||||
);
|
||||
throw new HttpError(
|
||||
502,
|
||||
'Cloudflare refused to remove the script; nothing was deleted.',
|
||||
{
|
||||
legacyCode: 'worker_takedown_failed',
|
||||
cause: err,
|
||||
fields: { status: err.status },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!isEdgeTransportError(err)) throw err;
|
||||
console.warn(
|
||||
`[workers] edge delete of ${workerName} did not complete`,
|
||||
err,
|
||||
);
|
||||
throw new HttpError(
|
||||
504,
|
||||
'The script delete did not complete; the worker is kept and a retry finishes it.',
|
||||
{ legacyCode: 'worker_takedown_timeout', cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers ------------------------------------------------------
|
||||
@@ -1093,9 +1254,15 @@ export class WorkerDriver extends PuterDriver {
|
||||
// owner's account) still has a deployed script to take down.
|
||||
this.clients.event.on(
|
||||
'subdomain.delete',
|
||||
(_key: string, data: { subdomain?: unknown }) => {
|
||||
(_key: string, data: { subdomain?: unknown; uid?: unknown }) => {
|
||||
const subdomain = String(data?.subdomain ?? '');
|
||||
if (!subdomain.startsWith(WORKER_SUBDOMAIN_PREFIX)) return;
|
||||
// Its own announcement: that row is already fully torn down.
|
||||
const uid = String(data?.uid ?? '');
|
||||
if (uid && this.#tornDown.has(uid)) {
|
||||
this.#tornDown.delete(uid);
|
||||
return;
|
||||
}
|
||||
const workerName = subdomain.slice(
|
||||
WORKER_SUBDOMAIN_PREFIX.length,
|
||||
);
|
||||
|
||||
@@ -52,7 +52,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",
|
||||
@@ -60,12 +60,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"
|
||||
},
|
||||
|
||||
@@ -441,6 +441,7 @@ describe('PuterServer host header validation — permissive modes', () => {
|
||||
allow_no_host_header: false,
|
||||
custom_domains_enabled: true,
|
||||
allow_nipio_domains: true,
|
||||
static_hosting_domain: 'site.puter.localhost',
|
||||
} as unknown as IConfig,
|
||||
{ listen: true },
|
||||
);
|
||||
@@ -450,11 +451,13 @@ describe('PuterServer host header validation — permissive modes', () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('lets an unknown host through when custom domains are enabled', async () => {
|
||||
it('hands an unknown host to site hosting when custom domains are enabled', async () => {
|
||||
const res = await rawRequest(port, '/', {
|
||||
host: 'my-own-domain.example',
|
||||
});
|
||||
expect(res.status).not.toBe(400);
|
||||
// No site claims it, so it 404s instead of reaching the GUI.
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toBe('Site not found');
|
||||
});
|
||||
|
||||
it('accepts nip.io hosts when they are opted in', async () => {
|
||||
|
||||
+18
-1
@@ -49,6 +49,7 @@ import {
|
||||
requireReputationGate,
|
||||
requireSubscriptionGate,
|
||||
requireTeam2fa,
|
||||
type Team2faLookup,
|
||||
requireVerifiedAccount,
|
||||
requireNonAccessTokenGate,
|
||||
requireUserActorGate,
|
||||
@@ -209,6 +210,20 @@ export class PuterServer {
|
||||
[this.#config, this.clients, this.stores, this.services],
|
||||
);
|
||||
|
||||
// Core's seat rules skip silently without Teams, so asking for it with
|
||||
// nothing registered refuses to boot rather than serve without them.
|
||||
if (
|
||||
this.#config.teams_enabled === true &&
|
||||
!(
|
||||
(this.stores as { team?: unknown }).team &&
|
||||
(this.services as { team?: unknown }).team
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'`teams_enabled` is on, but no extension registered the team store and service',
|
||||
);
|
||||
}
|
||||
|
||||
// Wire the rate-limiter to its configured backend now that clients
|
||||
// and stores exist. Memory is the default; `redis` needs a redis
|
||||
// client, `kv` needs the system KV store (DynamoDB-backed).
|
||||
@@ -1038,7 +1053,9 @@ export class PuterServer {
|
||||
if (needsAuth && !opts.allowUnconfirmed) {
|
||||
mwChain.push(requireVerifiedAccount());
|
||||
// Same opt-out: a member owing 2FA must still reach those routes.
|
||||
mwChain.push(requireTeam2fa(this.stores?.team));
|
||||
mwChain.push(
|
||||
requireTeam2fa((this.stores as { team?: Team2faLookup })?.team),
|
||||
);
|
||||
}
|
||||
|
||||
// block access tokens by default
|
||||
|
||||
@@ -3316,6 +3316,29 @@ describe('FSService reads', () => {
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('reports 416 when the range starts past the end of the object', async () => {
|
||||
const entry = await writeFile(
|
||||
user,
|
||||
`${user.home}/Documents/short.txt`,
|
||||
'abc',
|
||||
);
|
||||
const getObjectStream = vi
|
||||
.spyOn(server.stores.s3Object, 'getObjectStream')
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('range not satisfiable'), {
|
||||
name: 'InvalidRange',
|
||||
}),
|
||||
);
|
||||
|
||||
const error = await caught(() =>
|
||||
fs.readContent(entry, { range: 'bytes=10-' }),
|
||||
);
|
||||
expect(error.statusCode).toBe(416);
|
||||
expect(await entryAt(user, '/Documents/short.txt')).not.toBeNull();
|
||||
|
||||
getObjectStream.mockRestore();
|
||||
});
|
||||
|
||||
it('propagates a non-NoSuchKey storage failure unchanged', async () => {
|
||||
const entry = await writeFile(
|
||||
user,
|
||||
|
||||
@@ -175,6 +175,12 @@ const isNoSuchKeyError = (err: unknown): boolean => {
|
||||
return e.name === 'NoSuchKey' || e.Code === 'NoSuchKey';
|
||||
};
|
||||
|
||||
const isInvalidRangeError = (err: unknown): boolean => {
|
||||
if (!err || typeof err !== 'object') return false;
|
||||
const e = err as { name?: unknown; Code?: unknown };
|
||||
return e.name === 'InvalidRange' || e.Code === 'InvalidRange';
|
||||
};
|
||||
|
||||
/** Also recorded as the failure reason on a session refused for it. */
|
||||
const UPLOAD_NOT_RECEIVED = 'Upload content was not received';
|
||||
const uploadNotReceivedError = (): HttpError =>
|
||||
@@ -3886,6 +3892,13 @@ export class FSService extends PuterService {
|
||||
},
|
||||
});
|
||||
}
|
||||
// Range starts at or past the end of the object.
|
||||
if (isInvalidRangeError(err)) {
|
||||
throw new HttpError(416, 'Requested range not satisfiable', {
|
||||
legacyCode: 'bad_request',
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,9 +445,11 @@ export class PuterHomepageService extends PuterService {
|
||||
const user = actor?.user;
|
||||
if (typeof user?.id !== 'number') return false;
|
||||
try {
|
||||
return await this.services.team.teamsAvailableTo(
|
||||
user.id,
|
||||
user.email ?? null,
|
||||
return (
|
||||
(await this.services.team?.teamsAvailableTo(
|
||||
user.id,
|
||||
user.email ?? null,
|
||||
)) ?? false
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -43,7 +43,6 @@ import { ProfileService } from './profile/ProfileService';
|
||||
import { DefaultUserService } from './selfhosted/DefaultUserService';
|
||||
import { ShareNotificationService } from './share/ShareNotificationService';
|
||||
import { ShareService } from './share/ShareService';
|
||||
import { TeamService } from './team/TeamService';
|
||||
import { SocketService } from './socket/SocketService';
|
||||
import { SubdomainPermissionService } from './subdomain/SubdomainPermissionService';
|
||||
import type { IPuterServiceRegistry } from './types';
|
||||
@@ -87,7 +86,6 @@ declare module './types' {
|
||||
homepage: PuterHomepageService;
|
||||
health: ServerHealthService;
|
||||
userAccount: UserAccountService;
|
||||
team: TeamService;
|
||||
signup: SignupService;
|
||||
}
|
||||
}
|
||||
@@ -123,8 +121,6 @@ export const puterServices = {
|
||||
// Declared after `fs` — account teardown tears the user's filesystem down
|
||||
// first.
|
||||
userAccount: UserAccountService,
|
||||
// Leaf: team + user stores only.
|
||||
team: TeamService,
|
||||
// Reaches `team` and `socket` at call time only.
|
||||
signup: SignupService,
|
||||
// AppPermissionService + SubdomainPermissionService register permission
|
||||
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
POLICY_PREFIX,
|
||||
USAGE_DETAIL_SHARD_COUNT,
|
||||
V1_CLAIM_THROUGH_MONTH,
|
||||
isFreeSubscription,
|
||||
subscriptionOverride,
|
||||
} from './consts.ts';
|
||||
import { subscriptionSatisfies } from './enforcement.ts';
|
||||
import type { MeteringService } from './MeteringService.ts';
|
||||
import type { UsageInput } from './types.ts';
|
||||
import { detailShardOf } from './usageDetail.ts';
|
||||
@@ -299,6 +302,67 @@ describe('MeteringService', () => {
|
||||
expect(await target.actorHasPaidSubscription(seat)).toBe(false);
|
||||
});
|
||||
|
||||
it('counts a plan registered as free as free wherever free-ness is asked', async () => {
|
||||
target.registerPolicy({
|
||||
id: 'registered-free',
|
||||
monthUsageAllowance: toMicroCents(1),
|
||||
monthlyStorageAllowance: 1024,
|
||||
free: true,
|
||||
});
|
||||
target.registerSubscriptionResolver(async () => 'registered-free');
|
||||
|
||||
const holder = makeActor();
|
||||
expect((await target.getActorSubscription(holder)).id).toBe(
|
||||
'registered-free',
|
||||
);
|
||||
expect(await target.actorHasPaidSubscription(holder)).toBe(false);
|
||||
expect(isFreeSubscription('registered-free')).toBe(true);
|
||||
expect(subscriptionSatisfies('registered-free', true)).toBe(false);
|
||||
expect(
|
||||
subscriptionOverride(
|
||||
{ [DEFAULT_FREE_SUBSCRIPTION]: 7 },
|
||||
'registered-free',
|
||||
),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it('lets a provisional answer win, but only for the fallback window', async () => {
|
||||
target.registerPolicy({
|
||||
id: 'provisional-plan',
|
||||
monthUsageAllowance: toMicroCents(1),
|
||||
monthlyStorageAllowance: 1024,
|
||||
});
|
||||
target.registerPolicy({
|
||||
id: 'later-default',
|
||||
monthUsageAllowance: toMicroCents(5),
|
||||
monthlyStorageAllowance: 1024,
|
||||
});
|
||||
const first = vi.fn(async () => ({
|
||||
id: 'provisional-plan',
|
||||
provisional: true,
|
||||
}));
|
||||
target.registerDefaultSubscriptionResolver(first, { priority: 10 });
|
||||
target.registerDefaultSubscriptionResolver(
|
||||
async () => 'later-default',
|
||||
);
|
||||
|
||||
const status = await target.getActorSubscriptionStatus(actor);
|
||||
expect(status.policy.id).toBe('provisional-plan');
|
||||
expect(status.provisional).toBe(true);
|
||||
|
||||
const { SUBSCRIPTION_FALLBACK_CACHE_MS: fallbackMs } =
|
||||
target.constructor as typeof MeteringService;
|
||||
const now = Date.now();
|
||||
const dateNow = vi.spyOn(Date, 'now');
|
||||
try {
|
||||
dateNow.mockReturnValue(now + fallbackMs + 1);
|
||||
await target.getActorSubscription(actor);
|
||||
expect(first).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the first non-empty subscription resolver', async () => {
|
||||
const customPolicy = {
|
||||
id: 'custom-paid',
|
||||
@@ -745,6 +809,129 @@ describe('MeteringService', () => {
|
||||
|
||||
// ── Addons ───────────────────────────────────────────────────────
|
||||
|
||||
describe('getActorSubscriptionStatus', () => {
|
||||
it('answers a resolved plan as not provisional', async () => {
|
||||
target.registerPolicy({
|
||||
id: 'status-paid',
|
||||
monthUsageAllowance: toMicroCents(5),
|
||||
monthlyStorageAllowance: 1024,
|
||||
});
|
||||
target.registerSubscriptionResolver(async () => 'status-paid');
|
||||
|
||||
const status = await target.getActorSubscriptionStatus(actor);
|
||||
expect(status).toMatchObject({
|
||||
policy: { id: 'status-paid' },
|
||||
paid: true,
|
||||
provisional: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the fallback a failed resolver causes as provisional, cached or not', async () => {
|
||||
target.registerSubscriptionResolver(async () => {
|
||||
throw new Error('store down');
|
||||
});
|
||||
|
||||
const first = await target.getActorSubscriptionStatus(actor);
|
||||
expect(first).toMatchObject({
|
||||
policy: { id: DEFAULT_FREE_SUBSCRIPTION },
|
||||
paid: false,
|
||||
provisional: true,
|
||||
});
|
||||
const again = await target.getActorSubscriptionStatus(actor);
|
||||
expect(again.provisional).toBe(true);
|
||||
// The plain read still answers with the fallback.
|
||||
expect((await target.getActorSubscription(actor)).id).toBe(
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
);
|
||||
});
|
||||
|
||||
const pastFallbackWindow = async (run: () => Promise<void>) => {
|
||||
const { SUBSCRIPTION_FALLBACK_CACHE_MS: fallbackMs } =
|
||||
target.constructor as typeof MeteringService;
|
||||
const now = Date.now();
|
||||
const dateNow = vi.spyOn(Date, 'now');
|
||||
try {
|
||||
dateNow.mockReturnValue(now + fallbackMs + 1);
|
||||
await run();
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
};
|
||||
|
||||
for (const [label, defaultAnswer] of [
|
||||
['throws', () => Promise.reject(new Error('store down'))],
|
||||
[
|
||||
'answers provisionally',
|
||||
async () => ({
|
||||
id: 'status-default',
|
||||
provisional: true,
|
||||
}),
|
||||
],
|
||||
] as const) {
|
||||
it(`keeps an explicit plan resolved when a default ${label}`, async () => {
|
||||
target.registerPolicy({
|
||||
id: 'status-default',
|
||||
monthUsageAllowance: toMicroCents(1),
|
||||
monthlyStorageAllowance: 1024,
|
||||
});
|
||||
target.registerPolicy({
|
||||
id: 'status-explicit',
|
||||
monthUsageAllowance: toMicroCents(5),
|
||||
monthlyStorageAllowance: 1024,
|
||||
});
|
||||
target.registerDefaultSubscriptionResolver(
|
||||
defaultAnswer as never,
|
||||
{ priority: 10 },
|
||||
);
|
||||
const explicit = vi.fn(async () => 'status-explicit');
|
||||
target.registerSubscriptionResolver(explicit);
|
||||
|
||||
expect(
|
||||
await target.getActorSubscriptionStatus(actor),
|
||||
).toMatchObject({
|
||||
policy: { id: 'status-explicit' },
|
||||
paid: true,
|
||||
provisional: false,
|
||||
});
|
||||
await pastFallbackWindow(async () => {
|
||||
await target.getActorSubscription(actor);
|
||||
});
|
||||
expect(explicit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
|
||||
it('marks a default that wins after another default threw as provisional', async () => {
|
||||
target.registerDefaultSubscriptionResolver(
|
||||
async () => {
|
||||
throw new Error('store down');
|
||||
},
|
||||
{ priority: 10 },
|
||||
);
|
||||
const status = await target.getActorSubscriptionStatus(actor);
|
||||
expect(status.provisional).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a provisional answer with no plan as provisional', async () => {
|
||||
target.registerSubscriptionResolver(async () => ({
|
||||
id: null,
|
||||
provisional: true,
|
||||
}));
|
||||
const status = await target.getActorSubscriptionStatus(actor);
|
||||
expect(status).toMatchObject({
|
||||
policy: { id: DEFAULT_FREE_SUBSCRIPTION },
|
||||
provisional: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks an answer for an actor missing a keyed field as provisional', async () => {
|
||||
const { id: _id, ...partialUser } = actor.user;
|
||||
const status = await target.getActorSubscriptionStatus({
|
||||
user: partialUser,
|
||||
});
|
||||
expect(status.provisional).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActorAddons / updateAddonCredit', () => {
|
||||
it('returns an empty addon map for a fresh user', async () => {
|
||||
const addons = await target.getActorAddons(actor);
|
||||
@@ -2414,11 +2601,11 @@ describe('MeteringService', () => {
|
||||
});
|
||||
|
||||
it('leaves free policies alone without reading for an override', async () => {
|
||||
await target.setMonthAllowance(
|
||||
actor.user.uuid!,
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
1,
|
||||
);
|
||||
// Written directly: `setMonthAllowance` refuses a free plan.
|
||||
await server.stores.kv.set({
|
||||
key: `${POLICY_PREFIX}:actor:${actor.user.uuid}:allowance:${DEFAULT_FREE_SUBSCRIPTION}:${currentMonthString()}`,
|
||||
value: 1,
|
||||
});
|
||||
const getSpy = vi.spyOn(server.stores.kv, 'get');
|
||||
|
||||
const policy = await target.getActorSubscription(actor);
|
||||
@@ -2508,6 +2695,31 @@ describe('MeteringService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses an allowance for a plan that never takes one', async () => {
|
||||
target.registerPolicy({
|
||||
id: 'month-free',
|
||||
monthUsageAllowance: toMicroCents(1),
|
||||
monthlyStorageAllowance: 0,
|
||||
free: true,
|
||||
});
|
||||
target.registerPolicy({
|
||||
id: 'month-none',
|
||||
monthUsageAllowance: 0,
|
||||
monthlyStorageAllowance: 0,
|
||||
noAllowance: true,
|
||||
});
|
||||
for (const id of [
|
||||
'month-free',
|
||||
'month-none',
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
UNLIMITED_SUBSCRIPTION,
|
||||
]) {
|
||||
await expect(
|
||||
target.setMonthAllowance(actor.user.uuid!, id, 1_000),
|
||||
).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('has no month allowance for a policy nobody registered', async () => {
|
||||
expect(
|
||||
await target.getMonthAllowance(actor.user.uuid!, 'ghost-plan'),
|
||||
@@ -2925,6 +3137,132 @@ describe('MeteringService', () => {
|
||||
addonsSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('a plan that grants no allowance', () => {
|
||||
const onNoAllowancePlan = () => {
|
||||
target.registerPolicy({
|
||||
id: 'test-no-allowance',
|
||||
monthUsageAllowance: toMicroCents(1),
|
||||
monthlyStorageAllowance: 0,
|
||||
noAllowance: true,
|
||||
});
|
||||
target.registerSubscriptionResolver(() => 'test-no-allowance');
|
||||
};
|
||||
|
||||
it('has no allowance, whatever the policy states', async () => {
|
||||
onNoAllowancePlan();
|
||||
const sub = await target.getActorSubscription(actor);
|
||||
expect(sub.monthUsageAllowance).toBe(0);
|
||||
});
|
||||
|
||||
it('is out of budget without purchased credit', async () => {
|
||||
onNoAllowancePlan();
|
||||
expect(await target.hasAnyUsageCached(actor)).toBe(false);
|
||||
expect(await target.hasEnoughCredits(actor, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('spends purchased credit, and only that', async () => {
|
||||
onNoAllowancePlan();
|
||||
expect(await target.hasAnyUsageCached(actor)).toBe(false);
|
||||
await target.updateAddonCredit(actor.user.uuid!, 5_000);
|
||||
expect(await target.hasAnyUsageCached(actor)).toBe(true);
|
||||
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 2_000);
|
||||
const addons = await target.getActorAddons(actor);
|
||||
expect(addons.consumedPurchaseCredits).toBe(2_000);
|
||||
});
|
||||
|
||||
const creditStates = () => {
|
||||
const states: string[] = [];
|
||||
const seen = (_key: string, event: { state: string }) => {
|
||||
states.push(event.state);
|
||||
};
|
||||
server.clients.event.on('metering.credit-state', seen as never);
|
||||
return {
|
||||
states,
|
||||
stop: () =>
|
||||
server.clients.event.off(
|
||||
'metering.credit-state',
|
||||
seen as never,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
it('announces nothing for an actor that never had budget', async () => {
|
||||
onNoAllowancePlan();
|
||||
const { states, stop } = creditStates();
|
||||
try {
|
||||
expect(await target.hasAnyUsageCached(actor)).toBe(false);
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 1_000);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(states).toEqual([]);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('announces exhaustion when an actor moves onto it from a plan with budget', async () => {
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 1);
|
||||
const { states, stop } = creditStates();
|
||||
try {
|
||||
onNoAllowancePlan();
|
||||
target.invalidateActorSubscription(actor.user.uuid!);
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 1);
|
||||
await waitFor(() => expect(states).toEqual(['exhausted']));
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('announces exhaustion once when purchased credit runs out', async () => {
|
||||
onNoAllowancePlan();
|
||||
await target.updateAddonCredit(actor.user.uuid!, 1_000);
|
||||
const { states, stop } = creditStates();
|
||||
try {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 1_000);
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(states.filter((s) => s === 'exhausted')).toEqual(
|
||||
['exhausted'],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the whole allowance to a plan switched to later in the month', async () => {
|
||||
onNoAllowancePlan();
|
||||
await target.updateAddonCredit(actor.user.uuid!, 5_000);
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 3_000);
|
||||
|
||||
const later = {
|
||||
id: 'test-later-allowance',
|
||||
monthUsageAllowance: 10_000,
|
||||
monthlyStorageAllowance: 0,
|
||||
};
|
||||
target.registerPolicy(later);
|
||||
internals.subscriptionResolvers.length = 0;
|
||||
target.registerSubscriptionResolver(
|
||||
() => 'test-later-allowance',
|
||||
);
|
||||
target.invalidateActorSubscription(actor.user.uuid!);
|
||||
|
||||
// Credit already paid for the earlier spend.
|
||||
expect((await target.getAllowedUsage(actor)).remaining).toBe(
|
||||
10_000 + 2_000,
|
||||
);
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 4_000);
|
||||
expect((await target.getAllowedUsage(actor)).remaining).toBe(
|
||||
6_000 + 2_000,
|
||||
);
|
||||
expect(
|
||||
(await target.getActorAddons(actor))
|
||||
.consumedPurchaseCredits,
|
||||
).toBe(3_000);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not block when the balance cannot be read', async () => {
|
||||
const failing = vi
|
||||
.spyOn(target, 'getActorAddons')
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
USAGE_DETAIL_SHARD_COUNT,
|
||||
V1_CLAIM_THROUGH_MONTH,
|
||||
isFreeSubscription,
|
||||
registerFreeSubscription,
|
||||
} from './consts';
|
||||
import { EGRESS_COSTS } from './costs';
|
||||
import type {
|
||||
@@ -69,13 +70,32 @@ import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
|
||||
type SubscriptionPolicy = (typeof SUB_POLICIES)[number] & {
|
||||
/** Seats a team owned by an account on this plan may provision. */
|
||||
teamSeatCap?: number;
|
||||
/** Counts as a free plan wherever `isFreeSubscription` is asked. */
|
||||
free?: boolean;
|
||||
/** Grants no allowance; without it, an allowance of 0 means unmetered. */
|
||||
noAllowance?: boolean;
|
||||
};
|
||||
|
||||
/** What `getActorSubscriptionStatus` answers. */
|
||||
export interface ActorSubscriptionStatus {
|
||||
policy: SubscriptionPolicy;
|
||||
/** Somebody subscribed the actor to it; a resolver did not infer it. */
|
||||
paid: boolean;
|
||||
/** May not be this actor's plan; cached only for the fallback window. */
|
||||
provisional: boolean;
|
||||
}
|
||||
|
||||
/** A `provisional` answer wins but is cached only for the fallback window. */
|
||||
export type SubscriptionAnswer =
|
||||
string | { id: string | null; provisional: boolean } | null | undefined;
|
||||
|
||||
export type SubscriptionResolver = (
|
||||
actor: Actor,
|
||||
) => Promise<string | null | undefined> | string | null | undefined;
|
||||
) => Promise<SubscriptionAnswer> | SubscriptionAnswer;
|
||||
|
||||
type ResolverOutcome = { id: string | null | undefined } | { error: unknown };
|
||||
type ResolverOutcome =
|
||||
| { id: string | null | undefined; provisional?: boolean }
|
||||
| { error: unknown };
|
||||
|
||||
/** One usage type's amount, pre-aggregated across one call. */
|
||||
type TypeAmounts = { units: number; cost: number; count: number };
|
||||
@@ -292,10 +312,10 @@ export class MeteringService extends PuterService {
|
||||
}
|
||||
>();
|
||||
|
||||
/** Uuid → the last budget state announced, so a retry loop emits once. */
|
||||
/** Uuid → last budget state sent; `rearmed` makes the next one news. */
|
||||
private creditAlertState = new Map<
|
||||
string,
|
||||
'ok' | 'near-limit' | 'exhausted'
|
||||
'ok' | 'near-limit' | 'exhausted' | 'rearmed'
|
||||
>();
|
||||
|
||||
/** Uuid → whether the actor had budget left. See CREDIT_CACHE_MS. */
|
||||
@@ -482,9 +502,12 @@ export class MeteringService extends PuterService {
|
||||
|
||||
// -- Extension hooks ----------------------------------------------
|
||||
|
||||
/** Register a policy that should be available to actors. */
|
||||
/** Register a policy for actors; `free` applies process-wide, by id. */
|
||||
registerPolicy(policy: SubscriptionPolicy): void {
|
||||
this.extraPolicies.push(policy);
|
||||
if (policy.free) registerFreeSubscription(policy.id);
|
||||
this.extraPolicies.push(
|
||||
policy.noAllowance ? { ...policy, monthUsageAllowance: 0 } : policy,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -671,7 +694,7 @@ export class MeteringService extends PuterService {
|
||||
userId,
|
||||
actorUsageKey,
|
||||
actorUsages,
|
||||
actorSubscription.monthUsageAllowance,
|
||||
actorSubscription,
|
||||
actorAddons,
|
||||
totalBatchCost,
|
||||
);
|
||||
@@ -689,7 +712,7 @@ export class MeteringService extends PuterService {
|
||||
this.rememberRemainingCredits(
|
||||
userId,
|
||||
settledAllowanceUsed,
|
||||
actorSubscription.monthUsageAllowance,
|
||||
actorSubscription,
|
||||
actorAddons,
|
||||
provisionalUntil,
|
||||
);
|
||||
@@ -1646,6 +1669,11 @@ export class MeteringService extends PuterService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Nothing to run out of, unless the plan says it grants nothing. */
|
||||
private static isUnmetered(policy: SubscriptionPolicy): boolean {
|
||||
return !policy.noAllowance && !(policy.monthUsageAllowance > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* What's left of an actor's budget: what remains of the monthly allowance
|
||||
* plus what remains of the lifetime credit pool.
|
||||
@@ -1753,7 +1781,8 @@ export class MeteringService extends PuterService {
|
||||
this.#spentAddons.delete(userUuid);
|
||||
this.#spentAddonsDrops++;
|
||||
// Added capacity re-arms the alert: the next exhaustion is news again.
|
||||
this.creditAlertState.delete(userUuid);
|
||||
if (this.creditAlertState.has(userUuid))
|
||||
this.creditAlertState.set(userUuid, 'rearmed');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1786,10 +1815,8 @@ export class MeteringService extends PuterService {
|
||||
try {
|
||||
const { policy: subscription, provisionalUntil } =
|
||||
await this.#actorSubscriptionWithStatus(actor);
|
||||
// A non-positive allowance is how a policy says it isn't metered
|
||||
// (the overuse alarm reads it the same way) — no budget to run out
|
||||
// of, and no reason to pay for the reads below.
|
||||
if (!(subscription.monthUsageAllowance > 0)) {
|
||||
// Nothing to run out of, so no reason to pay for the reads below.
|
||||
if (MeteringService.isUnmetered(subscription)) {
|
||||
this.rememberHasCredits(uuid, true, provisionalUntil);
|
||||
return;
|
||||
}
|
||||
@@ -1803,7 +1830,7 @@ export class MeteringService extends PuterService {
|
||||
totals,
|
||||
subscription.monthUsageAllowance,
|
||||
),
|
||||
subscription.monthUsageAllowance,
|
||||
subscription,
|
||||
addons,
|
||||
provisionalUntil,
|
||||
);
|
||||
@@ -1838,14 +1865,15 @@ export class MeteringService extends PuterService {
|
||||
private rememberRemainingCredits(
|
||||
userId: string,
|
||||
allowanceUsed: number,
|
||||
monthUsageAllowance: number,
|
||||
subscription: SubscriptionPolicy,
|
||||
addons: UsageAddons | null | undefined,
|
||||
provisionalUntil: number | undefined,
|
||||
): void {
|
||||
if (!(monthUsageAllowance > 0)) {
|
||||
if (MeteringService.isUnmetered(subscription)) {
|
||||
this.rememberHasCredits(userId, true, provisionalUntil);
|
||||
return;
|
||||
}
|
||||
const { monthUsageAllowance } = subscription;
|
||||
const remaining = MeteringService.remainingFrom(
|
||||
allowanceUsed,
|
||||
monthUsageAllowance,
|
||||
@@ -1874,6 +1902,8 @@ export class MeteringService extends PuterService {
|
||||
// Purchased credits are spendable, so the allowance alone warns early.
|
||||
const capacity =
|
||||
(monthUsageAllowance || 0) + (addons?.purchasedCredits || 0);
|
||||
// No budget, and none seen before: there is no crossing to announce.
|
||||
if (!(capacity > 0) && !this.creditAlertState.has(userUuid)) return;
|
||||
const state =
|
||||
remaining <= 0
|
||||
? 'exhausted'
|
||||
@@ -1971,11 +2001,20 @@ export class MeteringService extends PuterService {
|
||||
|
||||
/** On a plan somebody subscribed them to, not one a resolver inferred. */
|
||||
async actorHasPaidSubscription(actor: Actor): Promise<boolean> {
|
||||
const { policy, fromDefault } =
|
||||
return (await this.getActorSubscriptionStatus(actor)).paid;
|
||||
}
|
||||
|
||||
/** The plan, whether it is paid, and whether the answer is provisional. */
|
||||
async getActorSubscriptionStatus(
|
||||
actor: Actor,
|
||||
): Promise<ActorSubscriptionStatus> {
|
||||
const { policy, fromDefault, provisionalUntil } =
|
||||
await this.#actorSubscriptionWithStatus(actor);
|
||||
// A deployment that turned unlimited on means it.
|
||||
if (policy.id === UNLIMITED_SUBSCRIPTION) return true;
|
||||
return !fromDefault && !isFreeSubscription(policy.id);
|
||||
const paid =
|
||||
policy.id === UNLIMITED_SUBSCRIPTION ||
|
||||
(!fromDefault && !isFreeSubscription(policy.id));
|
||||
return { policy, paid, provisional: provisionalUntil !== undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2062,7 +2101,7 @@ export class MeteringService extends PuterService {
|
||||
};
|
||||
}
|
||||
|
||||
/** `lookupFailed`: a resolver or the allowance read threw, so it fell back. */
|
||||
/** `lookupFailed`: the applied side failed, or the allowance read did. */
|
||||
async #resolveActorSubscription(actor: Actor): Promise<{
|
||||
policy: SubscriptionPolicy;
|
||||
lookupFailed: boolean;
|
||||
@@ -2087,7 +2126,6 @@ export class MeteringService extends PuterService {
|
||||
const resolvedDefault = defaults.id || fallbackDefault;
|
||||
const user = await this.firstResolver(pendingUser);
|
||||
const resolvedUser = user.id || resolvedDefault;
|
||||
const resolverFailed = defaults.failed || user.failed;
|
||||
|
||||
const availablePolicies: SubscriptionPolicy[] = [
|
||||
...this.extraPolicies,
|
||||
@@ -2109,6 +2147,8 @@ export class MeteringService extends PuterService {
|
||||
availablePolicies.find((p) => p.id === fallbackDefault);
|
||||
// Of the policy that applied, not the id that was asked for.
|
||||
const fromDefault = !user.id || !explicit;
|
||||
// A default's failure says nothing about an explicit plan that won.
|
||||
const resolverFailed = user.failed || (fromDefault && defaults.failed);
|
||||
if (policy) {
|
||||
const withAllowance = await this.#withMonthAllowance(
|
||||
actor.user.uuid!,
|
||||
@@ -2142,11 +2182,7 @@ export class MeteringService extends PuterService {
|
||||
policy: SubscriptionPolicy,
|
||||
month: string = this.monthYearString(),
|
||||
): Promise<{ policy: SubscriptionPolicy; failed: boolean }> {
|
||||
if (
|
||||
isFreeSubscription(policy.id) ||
|
||||
policy.id === UNLIMITED_SUBSCRIPTION ||
|
||||
!(policy.monthUsageAllowance > 0)
|
||||
) {
|
||||
if (!MeteringService.takesMonthAllowance(policy)) {
|
||||
return { policy, failed: false };
|
||||
}
|
||||
try {
|
||||
@@ -2174,6 +2210,17 @@ export class MeteringService extends PuterService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Free, unlimited and allowance-less plans never take a month override. */
|
||||
private static takesMonthAllowance(
|
||||
policy: Pick<SubscriptionPolicy, 'id' | 'monthUsageAllowance'>,
|
||||
): boolean {
|
||||
return (
|
||||
!isFreeSubscription(policy.id) &&
|
||||
policy.id !== UNLIMITED_SUBSCRIPTION &&
|
||||
policy.monthUsageAllowance > 0
|
||||
);
|
||||
}
|
||||
|
||||
#monthAllowanceKey(
|
||||
userUuid: string,
|
||||
policyId: string,
|
||||
@@ -2357,6 +2404,14 @@ export class MeteringService extends PuterService {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error('Month allowance must be a positive number');
|
||||
}
|
||||
// An unregistered plan is judged by its id alone.
|
||||
const policy = this.getRegisteredPolicy(policyId) ?? {
|
||||
id: policyId,
|
||||
monthUsageAllowance: value,
|
||||
};
|
||||
if (!MeteringService.takesMonthAllowance(policy)) {
|
||||
throw new Error(`Plan ${policyId} never takes a month allowance`);
|
||||
}
|
||||
const month = opts.month ?? this.monthYearString();
|
||||
const parsed = /^(\d{4})-(\d{2})$/.exec(month);
|
||||
if (!parsed) throw new Error('Month must be YYYY-MM');
|
||||
@@ -2494,24 +2549,24 @@ export class MeteringService extends PuterService {
|
||||
): Promise<ResolverOutcome>[] {
|
||||
return resolvers.map(async (resolver): Promise<ResolverOutcome> => {
|
||||
try {
|
||||
return { id: await resolver(actor) };
|
||||
const answer = await resolver(actor);
|
||||
if (typeof answer === 'string') return { id: answer };
|
||||
if (!answer) return { id: null };
|
||||
return { id: answer.id, provisional: answer.provisional };
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The first non-empty answer in registration order, and whether a resolver
|
||||
* ahead of it threw. Outcomes after the winner are ignored, as if never
|
||||
* asked.
|
||||
*/
|
||||
/** First answer; `failed` if one up to it threw or was provisional. */
|
||||
private async firstResolver(
|
||||
pending: Promise<ResolverOutcome>[],
|
||||
): Promise<{ id: string | null; failed: boolean }> {
|
||||
let failed = false;
|
||||
for (const next of pending) {
|
||||
const outcome = await next;
|
||||
if ('provisional' in outcome && outcome.provisional) failed = true;
|
||||
if ('error' in outcome) {
|
||||
failed = true;
|
||||
console.warn(
|
||||
@@ -2728,10 +2783,11 @@ export class MeteringService extends PuterService {
|
||||
userId: string,
|
||||
actorUsageKey: string,
|
||||
usageRecord: UsageByType,
|
||||
monthUsageAllowance: number,
|
||||
subscription: SubscriptionPolicy,
|
||||
addons: UsageAddons,
|
||||
incrementCost: number,
|
||||
): Promise<number> {
|
||||
const { monthUsageAllowance } = subscription;
|
||||
if (incrementCost <= 0) {
|
||||
return MeteringService.allowanceUsedFrom(
|
||||
usageRecord,
|
||||
@@ -2763,6 +2819,18 @@ export class MeteringService extends PuterService {
|
||||
const overage = incrementCost - allowanceCharge;
|
||||
|
||||
const writes: Promise<unknown>[] = [];
|
||||
// Recorded, so a later plan with an allowance doesn't count this spend.
|
||||
if (
|
||||
subscription.noAllowance &&
|
||||
usageRecord.allowanceUsed === undefined
|
||||
) {
|
||||
writes.push(
|
||||
this.stores.meteringBuffer.incr({
|
||||
key: actorUsageKey,
|
||||
pathAndAmountMap: { allowanceUsed: 0 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (baseline + allowanceCharge > 0) {
|
||||
writes.push(
|
||||
this.stores.meteringBuffer.incr({
|
||||
|
||||
@@ -78,9 +78,16 @@ export const FREE_SUBSCRIPTION_IDS: ReadonlySet<string> = new Set([
|
||||
ORG_SEAT_FREE_SUBSCRIPTION,
|
||||
]);
|
||||
|
||||
/** Plans registered as `free`; see `MeteringService.registerPolicy`. */
|
||||
const registeredFreeIds = new Set<string>();
|
||||
|
||||
export const registerFreeSubscription = (id: string): void => {
|
||||
registeredFreeIds.add(id);
|
||||
};
|
||||
|
||||
/** Whether a policy id is a free plan. Check free-ness here, not on the set. */
|
||||
export const isFreeSubscription = (id: string): boolean =>
|
||||
FREE_SUBSCRIPTION_IDS.has(id);
|
||||
FREE_SUBSCRIPTION_IDS.has(id) || registeredFreeIds.has(id);
|
||||
|
||||
/** Where a default-subscription resolver sits; lower wins. */
|
||||
export const ORG_SEAT_RESOLVER_PRIORITY = 10;
|
||||
|
||||
@@ -1,362 +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 type { Actor } from '../../core/actor';
|
||||
import {
|
||||
setupTwoTeams,
|
||||
type TwoTeams,
|
||||
} from '../../testFixtures/twoTeams.js';
|
||||
|
||||
describe('group grants', () => {
|
||||
let fx: TwoTeams;
|
||||
|
||||
/** A plain user actor, which is what a grant is issued as. */
|
||||
const actorFor = async (userId: number): Promise<Actor> => {
|
||||
const user = await fx.env.server.stores.user.getById(userId);
|
||||
return { user } as unknown as Actor;
|
||||
};
|
||||
|
||||
/** Written directly: these tests are about granting, not about writing. */
|
||||
const makeFile = async (ownerId: number) => {
|
||||
const uid = crypto.randomUUID();
|
||||
const name = `f_${uid.slice(0, 8)}.txt`;
|
||||
const owner = await fx.env.server.stores.user.getById(ownerId);
|
||||
const path = `/${owner!.username}/${name}`;
|
||||
await fx.env.server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
uid,
|
||||
name,
|
||||
path,
|
||||
ownerId,
|
||||
// `is_dir` is a real boolean on postgres.
|
||||
fx.env.server.clients.db.booleanValue(false),
|
||||
Math.floor(Date.now() / 1000),
|
||||
],
|
||||
);
|
||||
return { path, uid };
|
||||
};
|
||||
|
||||
const permissions = () => fx.env.server.services.permission;
|
||||
const store = () => fx.env.server.stores.permission;
|
||||
|
||||
beforeAll(async () => {
|
||||
fx = await setupTwoTeams();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await fx?.shutdown();
|
||||
});
|
||||
|
||||
// -- the fixture itself -------------------------------------------
|
||||
|
||||
it('builds two teams, each with its own owner and two seats', async () => {
|
||||
expect(fx.a.uid).not.toBe(fx.b.uid);
|
||||
expect(fx.a.owner.userId).not.toBe(fx.b.owner.userId);
|
||||
expect(fx.a.seats).toHaveLength(2);
|
||||
expect(fx.b.seats).toHaveLength(2);
|
||||
|
||||
// Distinct throughout, or it cannot tell this team from any.
|
||||
const ids = [
|
||||
fx.a.owner.userId,
|
||||
fx.b.owner.userId,
|
||||
...fx.a.seats.map((s) => s.userId),
|
||||
...fx.b.seats.map((s) => s.userId),
|
||||
fx.outsider.userId,
|
||||
];
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('runs against the shipped one-team-per-user cap', async () => {
|
||||
// Lifting the cap here would stop it resembling production.
|
||||
const cfg = fx.env.server.services.team.config as {
|
||||
max_teams_per_user?: number;
|
||||
};
|
||||
expect(cfg.max_teams_per_user ?? 1).toBe(1);
|
||||
});
|
||||
|
||||
it('seats can actually call the API', async () => {
|
||||
// An inert token would make every assertion below vacuous.
|
||||
const res = await fx.call('GET', `/teams/${fx.a.uid}`, fx.a.seats[0].token);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
// -- grants resolve for members, and only members -----------------
|
||||
|
||||
it('resolves a grant for every member of the team it was given to', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
|
||||
await permissions().grantUserGroupPermission(
|
||||
await actorFor(fx.outsider.userId),
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
|
||||
for (const seat of fx.a.seats) {
|
||||
const rows = await store().readUserGroupPerms(seat.userId, [
|
||||
permission,
|
||||
]);
|
||||
expect(rows, `seat ${seat.username}`).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not resolve for the other team', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
|
||||
await permissions().grantUserGroupPermission(
|
||||
await actorFor(fx.outsider.userId),
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
|
||||
// Why the fixture has two: with one, an unscoped query still passes.
|
||||
for (const seat of fx.b.seats) {
|
||||
const rows = await store().readUserGroupPerms(seat.userId, [
|
||||
permission,
|
||||
]);
|
||||
expect(rows, `B seat ${seat.username}`).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not resolve for a user in no team', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
|
||||
await permissions().grantUserGroupPermission(
|
||||
await actorFor(fx.a.owner.userId),
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
|
||||
const rows = await store().readUserGroupPerms(fx.outsider.userId, [
|
||||
permission,
|
||||
]);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
// -- revoke --------------------------------------------------------
|
||||
|
||||
it('revoking removes it for every member', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
const issuer = await actorFor(fx.outsider.userId);
|
||||
|
||||
await permissions().grantUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
const removed = await permissions().revokeUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
|
||||
expect(removed).toBe(true);
|
||||
for (const seat of fx.a.seats) {
|
||||
expect(
|
||||
await store().readUserGroupPerms(seat.userId, [permission]),
|
||||
).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('announces the revoke for every member, so their watches get settled', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
const issuer = await actorFor(fx.outsider.userId);
|
||||
const announced: number[] = [];
|
||||
const bus = fx.env.server.clients.event;
|
||||
const listen = ((_k: string, d: { holderUserId: number }) => {
|
||||
announced.push(d.holderUserId);
|
||||
}) as never;
|
||||
bus.on('permission.revoked', listen);
|
||||
|
||||
try {
|
||||
await permissions().grantUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
announced.length = 0;
|
||||
await permissions().revokeUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
} finally {
|
||||
bus.off?.('permission.revoked', listen);
|
||||
}
|
||||
|
||||
for (const seat of fx.a.seats) {
|
||||
expect(announced).toContain(seat.userId);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports false when there was nothing to revoke', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const removed = await permissions().revokeUserGroupPermission(
|
||||
await actorFor(fx.outsider.userId),
|
||||
fx.a.uid,
|
||||
`fs:${file.uid}:read`,
|
||||
);
|
||||
// Matching nothing is not an error, but a caller must be able to tell.
|
||||
expect(removed).toBe(false);
|
||||
});
|
||||
|
||||
it('one issuer revoking does not drop another issuer identical grant', async () => {
|
||||
// Revoker owns the file; the rival grant is written directly.
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
const permission = `fs:${file.uid}:write`;
|
||||
const groupId = (await store().resolveGroupId(fx.a.uid))!;
|
||||
|
||||
// The only shape where the DELETE's issuer scoping decides anything.
|
||||
await permissions().grantUserGroupPermission(
|
||||
await actorFor(fx.a.owner.userId),
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
await store().upsertUserGroupPerm(
|
||||
groupId,
|
||||
fx.outsider.userId,
|
||||
permission,
|
||||
{},
|
||||
);
|
||||
expect(
|
||||
await store().readUserGroupPerms(fx.a.seats[0].userId, [permission]),
|
||||
).toHaveLength(2);
|
||||
|
||||
await permissions().revokeUserGroupPermission(
|
||||
await actorFor(fx.a.owner.userId),
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
|
||||
// The owner's row is gone; the other issuer's survives.
|
||||
const left = await store().readUserGroupPerms(fx.a.seats[0].userId, [
|
||||
permission,
|
||||
]);
|
||||
expect(left).toHaveLength(1);
|
||||
expect(left[0].user_id).toBe(fx.outsider.userId);
|
||||
});
|
||||
|
||||
// -- authorization and shape --------------------------------------
|
||||
|
||||
it('refuses a grant the issuer has no authority over', async () => {
|
||||
const file = await makeFile(fx.b.owner.userId);
|
||||
|
||||
await expect(
|
||||
permissions().grantUserGroupPermission(
|
||||
await actorFor(fx.outsider.userId),
|
||||
fx.a.uid,
|
||||
`fs:${file.uid}:write`,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('404s on a group that does not exist', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
await expect(
|
||||
permissions().grantUserGroupPermission(
|
||||
await actorFor(fx.outsider.userId),
|
||||
'00000000-0000-4000-8000-000000000000',
|
||||
`fs:${file.uid}:read`,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('round-trips a path-form permission through grant and revoke', async () => {
|
||||
const owner = await fx.env.server.stores.user.getById(
|
||||
fx.outsider.userId,
|
||||
);
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const issuer = await actorFor(fx.outsider.userId);
|
||||
|
||||
// Both must collapse to the same `fs:<uuid>:` string, or nothing matches.
|
||||
const pathPerm = `fs:${file.path}:read`;
|
||||
await permissions().grantUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
pathPerm,
|
||||
);
|
||||
expect(owner).toBeTruthy();
|
||||
|
||||
const stored = await store().readUserGroupPerms(fx.a.seats[0].userId, [
|
||||
`fs:${file.uid}:read`,
|
||||
]);
|
||||
expect(stored).toHaveLength(1);
|
||||
|
||||
expect(
|
||||
await permissions().revokeUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
pathPerm,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('audits both the grant and the revoke', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
const issuer = await actorFor(fx.outsider.userId);
|
||||
|
||||
await permissions().grantUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
await permissions().revokeUserGroupPermission(
|
||||
issuer,
|
||||
fx.a.uid,
|
||||
permission,
|
||||
);
|
||||
|
||||
const rows = (await fx.env.server.clients.db.read(
|
||||
'SELECT `action` FROM `audit_user_to_group_permissions` ' +
|
||||
'WHERE `permission` = ? ORDER BY `id`',
|
||||
[permission],
|
||||
)) as { action: string }[];
|
||||
expect(rows.map((r) => r.action)).toEqual(['grant', 'revoke']);
|
||||
});
|
||||
|
||||
it('stops resolving once the team is soft-deleted', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
await permissions().grantUserGroupPermission(
|
||||
await actorFor(fx.outsider.userId),
|
||||
fx.b.uid,
|
||||
permission,
|
||||
);
|
||||
const seat = fx.b.seats[0].userId;
|
||||
expect(
|
||||
await store().readUserGroupPerms(seat, [permission]),
|
||||
).toHaveLength(1);
|
||||
|
||||
// Deletion suspends the seats but leaves memberships and grants, and
|
||||
// the uid stops resolving -- so this access would be unwithdrawable.
|
||||
await fx.env.server.stores.team.softDelete(fx.b.uid);
|
||||
|
||||
expect(
|
||||
await store().readUserGroupPerms(seat, [permission]),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
shareDeepLink,
|
||||
sharedViewLink,
|
||||
} from './shareDeepLink';
|
||||
import { NOTIFY_FANOUT_CAP } from '../../stores/team/TeamStore';
|
||||
import { NOTIFY_FANOUT_CAP } from '../../core/teams.js';
|
||||
import { abuseKey, cleanEmail, isBlockedEmail } from '../../util/email.js';
|
||||
import { blocksAllShares, type ResolvedShare } from './ShareService';
|
||||
import { withRedisLock } from '../../util/redisLock.js';
|
||||
@@ -370,9 +370,10 @@ export class ShareNotificationService extends PuterService {
|
||||
let members = byGroup.get(share.holderGroupId);
|
||||
if (!members) {
|
||||
try {
|
||||
members = await this.stores.team.listMemberIdsByGroupId(
|
||||
share.holderGroupId,
|
||||
);
|
||||
members =
|
||||
(await this.stores.team?.listMemberIdsByGroupId(
|
||||
share.holderGroupId,
|
||||
)) ?? [];
|
||||
} catch (err) {
|
||||
// Silence is this path's failure mode, so say so out loud.
|
||||
console.warn(
|
||||
|
||||
Loaded 100 of 291 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user