mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cc00fa977 |
No files matched your search
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -184,12 +184,10 @@ Recipes are tested, copy-pasteable patterns for a task, one layer above the per-
|
||||
- **Description.** Written for someone who has never heard of Puter. Start with "Learn how to", put the action first, and say what the reader's app gains: "Learn how to add file sharing to your app with Puter.js, so users can share files in their Puter account with other people." No API names, no mechanism, no list of example cases, and no "not just …" contrast.
|
||||
- **Intro.** Explain the situation and why you'd do this, tied to something the reader already knows ("`puter.kv` is your database in Puter.js"). No "Use this when" bullet list.
|
||||
- **Structure.** Get to the first working call as early as possible, then add validation, rate limits, and sign-in after it. Explain a Puter concept the first time it appears, such as what `me.puter` is or that `puter.workers.exec()` opens the Puter sign-in window. Split recipes by what the reader needs, not by technique, and give a family of recipes a shared tag.
|
||||
- **Reading order.** A recipe reads top to bottom. Each code block uses only functions and variables from blocks above it, so the reader never has to search further down the page to understand it. Start with a small version that works, and have each section add one case. When a section changes a function from earlier, show the whole new version of that function there. If the code is built up over several sections, end with a `## Full Code` block that has all of it.
|
||||
- **Link to the reference for full option lists.** Mention only the options the recipe uses, then link to the function page for the rest ("All the sort options are listed on [`puter.fs.readdir()`](/FS/readdir/)."). A copied list of options, rules, or scopes goes stale when the reference changes.
|
||||
- **Limits.** Mention a limit only when it's the reason for a pattern the recipe uses, such as one wildcard subscription instead of one per key, and link to [rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) or the function page for the number. Leave out limits that code following the recipe never reaches.
|
||||
- **Code.** Platform-neutral: just the code, with top-level `await`. No `<script>` or `<html>` wrapper, no IIFE.
|
||||
- **Tradeoffs.** Justify a choice with one scenario the reader can picture right away, in about two short paragraphs, or fold each point into the section where it matters. The description states the benefit only.
|
||||
- **Notes.** Leave out `## Notes` by default. Use it only to point to an architecturally different approach. A limit the recipe needs goes in the section it applies to, and 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 goes in the section it applies to; 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:26-bookworm-slim AS build
|
||||
FROM node:24-slim AS build
|
||||
|
||||
WORKDIR /opt/puter
|
||||
|
||||
@@ -59,7 +59,7 @@ RUN set -e; \
|
||||
wait $pjs_pid
|
||||
|
||||
# ---- Runtime stage (slim — no build tools) ----
|
||||
FROM node:26-bookworm-slim
|
||||
FROM node:24-slim
|
||||
|
||||
WORKDIR /opt/puter
|
||||
|
||||
|
||||
@@ -215,9 +215,7 @@
|
||||
// "bootstrapTables": true,
|
||||
"path": "",
|
||||
// Credentials. NOTE: snake_case here, unlike `s3.s3Config` below.
|
||||
// For dynamodb-local, any non-empty values work. On AWS,
|
||||
// `"useCredentialChain": true` uses the default credential chain
|
||||
// instead of keys.
|
||||
// For dynamodb-local, any non-empty values work.
|
||||
"aws": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
@@ -315,8 +313,7 @@
|
||||
// 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. Teams ships as an extension; on,
|
||||
// the server refuses to boot unless one registers it.
|
||||
// the feature without touching data.
|
||||
"teams_enabled": false,
|
||||
//
|
||||
// Live teams one user may own. Soft-deleted ones do not count, so
|
||||
@@ -433,8 +430,6 @@
|
||||
"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"
|
||||
@@ -497,8 +492,6 @@
|
||||
"defaultVoiceId": "",
|
||||
"speechToSpeechModelId": ""
|
||||
},
|
||||
// Both AWS providers also take `"useCredentialChain": true` in place
|
||||
// of keys, like `dynamo.aws`.
|
||||
"aws-polly": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
|
||||
@@ -45,8 +45,6 @@ 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.
|
||||
|
||||
+1
-4
@@ -51,10 +51,7 @@ two pages can't be decoded into how fast the table grows; `openCursor` still
|
||||
reads a plain cursor. What a cursor wraps is an implementation detail per
|
||||
store:
|
||||
|
||||
- DynamoDB-backed lists wrap `LastEvaluatedKey`. A kv listing of another
|
||||
namespace (cross-app or through a share handle) resumes after the last entry
|
||||
it returned instead, since the scan can stop on a private one, and its cursor
|
||||
is sealed to that listing and padded to one length.
|
||||
- DynamoDB-backed lists wrap `LastEvaluatedKey`.
|
||||
- SQL-backed lists wrap a keyset position — `(sortValue, id)` of the last
|
||||
row — and the query seeks past it (`WHERE (col, id) > (?, ?) ORDER BY col,
|
||||
id`). Any SQL list gaining a cursor must have a deterministic `ORDER BY`
|
||||
|
||||
+25
-13
@@ -1,7 +1,13 @@
|
||||
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';
|
||||
@@ -43,6 +49,7 @@ beforeAll(async () => {
|
||||
create_shortcut: true,
|
||||
payment_bypass: true,
|
||||
},
|
||||
teams_enabled: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
@@ -76,20 +83,21 @@ describe('whoami extension — handleWhoami', () => {
|
||||
};
|
||||
|
||||
const seatOf = async (teamName: string) => {
|
||||
const owner = await seedUser();
|
||||
const seat = await seedSeat();
|
||||
const team = {
|
||||
uid: `wt-${Math.random().toString(36).slice(2, 9)}`,
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id as number,
|
||||
name: teamName,
|
||||
};
|
||||
installSeatStub(server as never, [seat.id as number], {
|
||||
teamUid: team.uid,
|
||||
teamName: team.name,
|
||||
handle: `wt-${Math.random().toString(36).slice(2, 9)}`,
|
||||
});
|
||||
await server.stores.team.addMember(team.uid, seat.id as number, {
|
||||
orgOwned: true,
|
||||
});
|
||||
return { seat, team };
|
||||
};
|
||||
|
||||
it('names no team where teams is off', async () => {
|
||||
const { seat } = await seatOf('Acme Corp');
|
||||
it('names the team for a seat', async () => {
|
||||
const { seat, team } = await seatOf('Acme Corp');
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
@@ -97,8 +105,10 @@ describe('whoami extension — handleWhoami', () => {
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
// Off, so the seat is never looked up.
|
||||
expect((captured.body as { team?: unknown }).team).toBeUndefined();
|
||||
expect((captured.body as { team?: unknown }).team).toEqual({
|
||||
uid: team.uid,
|
||||
name: 'Acme Corp',
|
||||
});
|
||||
});
|
||||
|
||||
it('says nothing for an account that is not a seat', async () => {
|
||||
@@ -516,7 +526,9 @@ 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
+80
-92
@@ -1503,17 +1503,6 @@
|
||||
"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",
|
||||
@@ -6537,14 +6526,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz",
|
||||
"integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
|
||||
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -6558,8 +6547,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.12",
|
||||
"vitest": "4.1.12"
|
||||
"@vitest/browser": "4.1.11",
|
||||
"vitest": "4.1.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -6568,16 +6557,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -6586,13 +6575,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -6613,9 +6602,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6626,13 +6615,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.12",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -6640,14 +6629,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -6656,9 +6645,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -6666,13 +6655,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -7779,9 +7768,9 @@
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -9136,14 +9125,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier": {
|
||||
"version": "5.5.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.7.tgz",
|
||||
"integrity": "sha512-WjHyn3GrFd+ZR0rzsLiM4zJZ35PZMOE0SUIxzGmnWNG0126yNfJKJzObmmkYDwHJfGlkz3PSlzyG9ez/LJntwg==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prettier-linter-helpers": "^1.0.1",
|
||||
"synckit": "^0.12.3"
|
||||
"synckit": "^0.11.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
@@ -9892,9 +9881,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
|
||||
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
|
||||
"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==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -13374,9 +13363,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.24.6",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.6.tgz",
|
||||
"integrity": "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ==",
|
||||
"version": "3.24.5",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.5.tgz",
|
||||
"integrity": "sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
@@ -15589,13 +15578,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/smtp-server": {
|
||||
"version": "3.19.23",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.23.tgz",
|
||||
"integrity": "sha512-t6AOBe7RR9Y/9Kox4e5SIm7wS2ZA8P0VuhVEZ5Yiox0WyevX+rMjATzIwEX3A+B6SGwnJ8QvoHtJAhbJYbjy3A==",
|
||||
"version": "3.19.21",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.21.tgz",
|
||||
"integrity": "sha512-d0wWijB5dK4gImQidwWMlcGsycqcDCJV0VdQbfz61rgkDqnzAx0Epvi0o5JN2nnr/YwriVC+XV0T8YgcoB0bng==",
|
||||
"license": "MIT-0",
|
||||
"dependencies": {
|
||||
"ipv6-normalize": "1.0.1",
|
||||
"nodemailer": "10.1.2",
|
||||
"nodemailer": "10.1.0",
|
||||
"punycode.js": "2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -15603,9 +15592,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/smtp-server/node_modules/nodemailer": {
|
||||
"version": "10.1.2",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.2.tgz",
|
||||
"integrity": "sha512-rnQn9Rzh8RAwcp42MVsIvxbsv6YZ2a2/av+uyJ/LPU1+2QSlrRgFsWWZTKenyRUqkIl8urOI0c4+CEcmFdsvQw==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.0.tgz",
|
||||
"integrity": "sha512-fXW8AulB25aOTuwfRYV6lqHyfz2VM4rr4SGXH03mE7x5znEUpzL5mu6anARUpZN7744Ziwkmr4AxSLPJsBQspg==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -16014,13 +16003,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/synckit": {
|
||||
"version": "0.12.3",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.12.3.tgz",
|
||||
"integrity": "sha512-U+/AXT6LWsxnj2vz9HeUuPWBNgdWxjyIEjEPdbJARC9txmgPzo2VEdqnFo4hHaNyMPtycyVJrSaQYeQv/PoyyA==",
|
||||
"version": "0.11.13",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz",
|
||||
"integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dual-bundle/import-meta-resolve": "^4.2.1",
|
||||
"@pkgr/core": "^0.3.6"
|
||||
},
|
||||
"engines": {
|
||||
@@ -16619,9 +16607,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.30.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
|
||||
"integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
|
||||
"version": "7.30.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
|
||||
"integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
@@ -16865,19 +16853,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -16905,12 +16893,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.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",
|
||||
"@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",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
@@ -17716,7 +17704,7 @@
|
||||
"lorem-ipsum": "^2.0.8",
|
||||
"mime-types": "^2.1.35",
|
||||
"murmurhash": "^2.0.1",
|
||||
"mysql2": "^3.24.6",
|
||||
"mysql2": "^3.24.5",
|
||||
"nodemailer": "^9.1.1",
|
||||
"openai": "^6.34.0",
|
||||
"otpauth": "^9.5.2",
|
||||
@@ -17724,12 +17712,12 @@
|
||||
"postal-mime": "^3.0.1",
|
||||
"replicate": "^1.0.0",
|
||||
"sharp": "^0.35.5",
|
||||
"smtp-server": "^3.19.23",
|
||||
"smtp-server": "^3.19.21",
|
||||
"socket.io": "^4.8.4",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"together-ai": "^0.33.0",
|
||||
"ua-parser-js": "^1.0.41",
|
||||
"undici": "^7.30.1",
|
||||
"undici": "^7.29.1",
|
||||
"uuid": "^14.0.3",
|
||||
"validator": "^13.15.35"
|
||||
},
|
||||
@@ -19099,7 +19087,7 @@
|
||||
},
|
||||
"src/puter-js": {
|
||||
"name": "@heyputer/puter.js",
|
||||
"version": "2.6.5",
|
||||
"version": "2.6.4",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@heyputer/kv.js": "^0.2.1",
|
||||
|
||||
@@ -1,135 +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 { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PuterServer } from './server.ts';
|
||||
import { createTestUser, setupTestServer } from './testUtil.ts';
|
||||
|
||||
/**
|
||||
* Teams is optional, so core has to work when neither the service nor the store
|
||||
* is registered, which is how this test server boots.
|
||||
*/
|
||||
describe('core without a teams registration', () => {
|
||||
let server: PuterServer;
|
||||
|
||||
const makeUser = async (): Promise<{ id: number; username: string }> => {
|
||||
const username = `nt_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = (await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
})) as unknown as { id: number };
|
||||
return { id: created.id, username };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
// Without this the allowlist check returns before asking Teams.
|
||||
teams_allowed_email_domains: ['test.local'],
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('has no teams in the registry', () => {
|
||||
expect(server.services.team).toBeUndefined();
|
||||
expect(server.stores.team).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses to boot when the config asks for teams', async () => {
|
||||
await expect(
|
||||
setupTestServer({ teams_enabled: true } as never),
|
||||
).rejects.toThrow(/teams_enabled/);
|
||||
});
|
||||
|
||||
it('deletes an account without asking what it was charged for', async () => {
|
||||
const user = await makeUser();
|
||||
await expect(
|
||||
server.services.userAccount.cascadeDelete(user.id),
|
||||
).resolves.not.toThrow();
|
||||
expect(await server.stores.user.getById(user.id)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('answers the address-claim question signup asks', async () => {
|
||||
const user = await makeUser();
|
||||
const row = (await server.stores.user.getById(user.id))!;
|
||||
// Nothing is a seat, so the holder is returned as-is.
|
||||
await expect(
|
||||
server.services.signup.signupEmailHolder(row.email as string, {
|
||||
releaseSeat: true,
|
||||
}),
|
||||
).resolves.toMatchObject({ id: user.id });
|
||||
});
|
||||
|
||||
it('lists what is shared with an account, folding in no team shares', async () => {
|
||||
const user = await makeUser();
|
||||
await expect(
|
||||
server.services.share.listSharedWithMe({
|
||||
user: { id: user.id },
|
||||
} as never),
|
||||
).resolves.toMatchObject({ items: [] });
|
||||
});
|
||||
|
||||
it('refuses a share aimed at a team, rather than failing', async () => {
|
||||
// A real home directory, so the refusal is the recipient's and not
|
||||
// the entry's.
|
||||
const username = `nt_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const creds = await createTestUser(server, {
|
||||
username,
|
||||
password: 'Nt-Pw-12345!',
|
||||
});
|
||||
const row = (await server.stores.user.getByUsername(username))!;
|
||||
const user = { id: row.id, username };
|
||||
const actor = { user: row } as never;
|
||||
expect(creds).toBeTruthy();
|
||||
// The recipient cannot exist, so this is a 404 and not a crash.
|
||||
await expect(
|
||||
server.services.share.share(actor, {
|
||||
recipient: { teamHandle: 'nobody' },
|
||||
mode: 'read',
|
||||
path: `/${user.username}`,
|
||||
} as never),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('serves the homepage shell with the teams tab off', async () => {
|
||||
const user = await makeUser();
|
||||
const row = (await server.stores.user.getById(user.id))!;
|
||||
let html = '';
|
||||
const res = { send: (out: string) => (html = out) };
|
||||
await server.services.homepage.send(
|
||||
{
|
||||
req: { headers: {}, query: {}, get: () => undefined },
|
||||
res,
|
||||
actor: { user: row },
|
||||
} as never,
|
||||
{ title: 'Puter' } as never,
|
||||
{} as never,
|
||||
);
|
||||
const params = JSON.parse(
|
||||
/gui\((\{.*?\})\);/s.exec(html)![1].replaceAll('\\u003c', '<'),
|
||||
);
|
||||
expect(params.teams_ui).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 91;
|
||||
const CURRENT_SCHEMA_VERSION = 90;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
|
||||
@@ -129,7 +129,6 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[87, ['0092_share-entry-owner.sql']],
|
||||
[88, ['0093_fsentries-parent-uid-index.sql']],
|
||||
[89, ['0094_deleted-apps.sql']],
|
||||
[90, ['0095_kv-share-handle-levels.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -1,50 +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/>.
|
||||
|
||||
-- Share levels on `kv_share_handles`. See
|
||||
-- sqlite/0095_kv-share-handle-levels.sql for the rationale. No per-file
|
||||
-- applied-state tracking, so every step is guarded and a replay does nothing.
|
||||
|
||||
CALL _puter_add_col('kv_share_handles', 'revoked_by', '`revoked_by` varchar(8) DEFAULT NULL');
|
||||
CALL _puter_add_col('kv_share_handles', 'label', '`label` varchar(512) DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_indexes;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_kv_share_handle_indexes()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'kv_share_handles'
|
||||
AND INDEX_NAME = 'idx_kv_share_handles_grantee'
|
||||
) THEN
|
||||
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_grantee` (`grantee_user_id`, `id`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'kv_share_handles'
|
||||
AND INDEX_NAME = 'idx_kv_share_handles_app'
|
||||
) THEN
|
||||
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_app` (`app_uid`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_kv_share_handle_indexes();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_indexes;
|
||||
@@ -1,29 +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/>.
|
||||
|
||||
-- Share levels on kv_share_handles. See
|
||||
-- sqlite/0095_kv-share-handle-levels.sql for the rationale. Idempotent via
|
||||
-- IF NOT EXISTS; there is no per-file applied-state tracking.
|
||||
|
||||
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS revoked_by VARCHAR(8) DEFAULT NULL;
|
||||
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS label VARCHAR(512) DEFAULT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_grantee
|
||||
ON kv_share_handles (grantee_user_id, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_app
|
||||
ON kv_share_handles (app_uid);
|
||||
@@ -1,35 +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/>.
|
||||
|
||||
-- What share levels need from `kv_share_handles`. The level itself is not a
|
||||
-- column: it is the root of the stored `permission`.
|
||||
--
|
||||
-- - `revoked_by` : `owner`, `holder` or `system` (the namespace app was
|
||||
-- deleted). Null while live, and on rows retired before
|
||||
-- this existed.
|
||||
-- - `label` : optional owner-set text the holder is shown.
|
||||
-- - the grantee index serves the holder's listing and their unshare, and
|
||||
-- the app index retiring a deleted app's handles.
|
||||
|
||||
ALTER TABLE `kv_share_handles` ADD COLUMN `revoked_by` TEXT DEFAULT NULL;
|
||||
ALTER TABLE `kv_share_handles` ADD COLUMN `label` TEXT DEFAULT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_grantee`
|
||||
ON `kv_share_handles` (`grantee_user_id`, `id`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_app`
|
||||
ON `kv_share_handles` (`app_uid`);
|
||||
@@ -188,47 +188,6 @@ describe('DDBClient — item operations', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('puts only while its condition holds', async () => {
|
||||
const absent = {
|
||||
expression: 'attribute_not_exists(#pk)',
|
||||
names: { '#pk': 'pk' },
|
||||
};
|
||||
await client.put(
|
||||
TABLE,
|
||||
{ pk: 'u7', sk: 'once', v: 1 },
|
||||
{
|
||||
condition: absent,
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
client.put(
|
||||
TABLE,
|
||||
{ pk: 'u7', sk: 'once', v: 2 },
|
||||
{
|
||||
condition: absent,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ name: 'ConditionalCheckFailedException' });
|
||||
expect(
|
||||
(await client.get(TABLE, { pk: 'u7', sk: 'once' })).Item?.v,
|
||||
).toBe(1);
|
||||
|
||||
await client.put(
|
||||
TABLE,
|
||||
{ pk: 'u7', sk: 'once', v: 3 },
|
||||
{
|
||||
condition: {
|
||||
expression: '#v = :held',
|
||||
names: { '#v': 'v' },
|
||||
values: { ':held': 1 },
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(
|
||||
(await client.get(TABLE, { pk: 'u7', sk: 'once' })).Item?.v,
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('fetches keys from several tables in one batch', async () => {
|
||||
await client.put(TABLE, { pk: 'b1', sk: 'a', v: 1 });
|
||||
await client.put(TABLE, { pk: 'b1', sk: 'b', v: 2 });
|
||||
@@ -542,19 +501,6 @@ 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,10 +43,6 @@ 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';
|
||||
@@ -252,19 +248,7 @@ export class DDBClient extends PuterClient {
|
||||
}
|
||||
|
||||
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
|
||||
async put<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
item: T,
|
||||
opts?: {
|
||||
/** Refused (`ConditionalCheckFailedException`) unless it holds. */
|
||||
condition?: {
|
||||
expression: string;
|
||||
names?: Record<string, string>;
|
||||
values?: Record<string, unknown>;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const condition = opts?.condition;
|
||||
async put<T extends Record<string, unknown>>(table: string, item: T) {
|
||||
const client = await this.#getDocumentClient();
|
||||
return sendRepairingValues(
|
||||
item,
|
||||
@@ -274,23 +258,6 @@ export class DDBClient extends PuterClient {
|
||||
TableName: table,
|
||||
Item: itemToWrite,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
...(condition
|
||||
? {
|
||||
ConditionExpression: condition.expression,
|
||||
...(condition.names
|
||||
? {
|
||||
ExpressionAttributeNames:
|
||||
condition.names,
|
||||
}
|
||||
: {}),
|
||||
...(condition.values
|
||||
? {
|
||||
ExpressionAttributeValues:
|
||||
condition.values,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
() => `a put to ${table}`,
|
||||
@@ -721,15 +688,17 @@ export class DDBClient extends PuterClient {
|
||||
}
|
||||
|
||||
#bindAwsClient() {
|
||||
const credentials = awsCredentialsFrom(this.#ddbConfig.aws);
|
||||
if (!credentials) {
|
||||
const accessKeyId = this.#ddbConfig.aws?.access_key;
|
||||
const secretAccessKey = this.#ddbConfig.aws?.secret_key;
|
||||
|
||||
if (!accessKeyId || !secretAccessKey) {
|
||||
throw new Error(
|
||||
'DynamoDB aws config requires both `access_key` and `secret_key`, or `useCredentialChain`',
|
||||
'DynamoDB aws config requires both `access_key` and `secret_key`',
|
||||
);
|
||||
}
|
||||
|
||||
this.#bindClient({
|
||||
credentials,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
region: this.#ddbConfig.aws?.region || 'us-west-2',
|
||||
endpoint: this.#ddbConfig.endpoint,
|
||||
});
|
||||
@@ -751,7 +720,7 @@ export class DDBClient extends PuterClient {
|
||||
region,
|
||||
endpoint,
|
||||
}: {
|
||||
credentials: AwsCredentials;
|
||||
credentials: { accessKeyId: string; secretAccessKey: string };
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
}) {
|
||||
|
||||
@@ -397,8 +397,6 @@ export type EventMap = {
|
||||
user_id: number;
|
||||
user_uuid?: string;
|
||||
stripe_customer_id?: string | null;
|
||||
/** Apps the account owned, which cascaded away with it. */
|
||||
appUids?: string[];
|
||||
};
|
||||
|
||||
// ---- Team billing ---- the trigger, never the charge ----
|
||||
@@ -492,33 +490,13 @@ export type EventMap = {
|
||||
noShareKeys?: string[];
|
||||
/** Events handler runs behind the write; see `Actor.handlerDepth`. */
|
||||
handlerDepth?: number;
|
||||
/**
|
||||
* The user who made the change. Differs from `userId` when a share
|
||||
* holder wrote into the owner's namespace.
|
||||
*/
|
||||
actingUserId?: number;
|
||||
/** Made by a worker session or an events handler; see `viaWorkerOf`. */
|
||||
viaWorker?: true;
|
||||
};
|
||||
/**
|
||||
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
|
||||
* key enumeration is truncated for a large namespace, so the keys it saw
|
||||
* are not the keys it removed.
|
||||
*/
|
||||
'kv.flushed': {
|
||||
namespace: string;
|
||||
userId: number;
|
||||
actingUserId?: number;
|
||||
viaWorker?: true;
|
||||
};
|
||||
/**
|
||||
* Share handles stopped being addresses, whoever retired them. `userId` is
|
||||
* one user who could have subscribed through them, their owner or their
|
||||
* holder. Listeners end what was made through them even where another grant
|
||||
* still covers the region. Local, like `permission.revoked`: one region
|
||||
* settling covers every other.
|
||||
*/
|
||||
'kv.share.retired': { handles: string[]; userId: number };
|
||||
'kv.flushed': { namespace: string; userId: number };
|
||||
|
||||
// ---- Notifications ----
|
||||
/**
|
||||
@@ -701,10 +679,6 @@ 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,19 +3,18 @@
|
||||
*
|
||||
* 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/](https://www.gnu.org/licenses/).
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
@@ -25,7 +24,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, withPresignHeadroom } from './S3Client';
|
||||
import { S3Client } from './S3Client';
|
||||
|
||||
const LOCAL_BUCKET = 'puter-local';
|
||||
|
||||
@@ -488,54 +487,3 @@ 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,43 +43,6 @@ 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 {
|
||||
@@ -120,9 +83,7 @@ export class S3Client extends PuterClient {
|
||||
|
||||
if (useCredentialChain) {
|
||||
this.useProviderChain = true;
|
||||
this.awsConfig = {
|
||||
credentials: withPresignHeadroom(fromNodeProviderChain()),
|
||||
};
|
||||
this.awsConfig = { credentials: fromNodeProviderChain() };
|
||||
this.partSize = 64 * 1024 * 1024;
|
||||
this.maxSingleUploadSize = 128 * 1024 * 1024;
|
||||
} else {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
/**
|
||||
* HTTP-level coverage for `/auth/revoke-own-access-token` — the route that
|
||||
@@ -423,7 +424,7 @@ describe('2FA enrollment over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
env = await setupPuterTestEnv({ teams_enabled: true } as IConfig);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -560,6 +561,56 @@ 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,7 +28,6 @@
|
||||
* 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';
|
||||
@@ -49,7 +48,7 @@ import {
|
||||
} from '../../core/http/types.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
|
||||
import { kvSharePermission } from '../../services/kvShare/kvShares.js';
|
||||
import { kvSharePermission } from '../../services/events/kvShares.js';
|
||||
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { resetCardVerificationStatusCache } from '../../util/cardFallback.js';
|
||||
@@ -2151,7 +2150,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = `events:background`;
|
||||
const permission = `service:tg-app:ii:read`;
|
||||
// The controller's grant call delegates through PermissionService
|
||||
// (which uses ALS Context.set), so wrap in runWithContext.
|
||||
const res = makeRes();
|
||||
@@ -2192,7 +2191,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = `events:background`;
|
||||
const permission = `service:tg-origin-app:ii:read`;
|
||||
const res = makeRes();
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
@@ -2229,7 +2228,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = `events:background`;
|
||||
const permission = `service:tr-origin-app:ii:read`;
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
@@ -2294,7 +2293,7 @@ describe('AuthController grant flows', () => {
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleRevokeUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: 'events:background' },
|
||||
{ app_uid: app.uid, permission: 'service:x:ii:read' },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
@@ -2318,12 +2317,12 @@ describe('AuthController grant flows', () => {
|
||||
|
||||
it('grant-user-app: 400 on non-string or oversized origin/app_uid/permission', async () => {
|
||||
const cases = [
|
||||
{ origin: { host: 'evil' }, permission: 'events:background' },
|
||||
{ origin: 'https://a.test', permission: ['events:background'] },
|
||||
{ app_uid: 12345, permission: 'events:background' },
|
||||
{ origin: { host: 'evil' }, permission: 'service:x:ii:read' },
|
||||
{ origin: 'https://a.test', permission: ['service:x:ii:read'] },
|
||||
{ app_uid: 12345, permission: 'service:x:ii:read' },
|
||||
{
|
||||
origin: `https://${'a'.repeat(5000)}.test`,
|
||||
permission: 'events:background',
|
||||
permission: 'service:x:ii:read',
|
||||
},
|
||||
];
|
||||
for (const body of cases) {
|
||||
@@ -2354,7 +2353,7 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: target.id },
|
||||
);
|
||||
|
||||
const permission = 'events:background';
|
||||
const permission = 'service:squat:ii:read';
|
||||
for (const handler of [
|
||||
'handleGrantUserApp',
|
||||
'handleRevokeUserApp',
|
||||
@@ -2398,7 +2397,7 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: target.id },
|
||||
);
|
||||
|
||||
const permission = 'events:background';
|
||||
const permission = 'service:squat-beside:ii:read';
|
||||
for (const handler of [
|
||||
'handleGrantUserApp',
|
||||
'handleRevokeUserApp',
|
||||
@@ -2451,7 +2450,7 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
|
||||
const permission = 'events:background';
|
||||
const permission = 'service:tp-origin:ii:read';
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
@@ -2487,7 +2486,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = 'events:background';
|
||||
const permission = 'service:tm-app:ii:read';
|
||||
for (const body of [
|
||||
{ app_uid: app.uid, permission, meta: 'nope' },
|
||||
{ app_uid: app.uid, permission, meta: [1, 2] },
|
||||
@@ -2528,7 +2527,7 @@ describe('AuthController grant flows', () => {
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const permission = 'events:background';
|
||||
const permission = 'service:tb-app:ii:read';
|
||||
const oversized = { blob: 'z'.repeat(5000) };
|
||||
for (const body of [
|
||||
{ app_uid: app.uid, permission, extra: oversized },
|
||||
@@ -2605,278 +2604,26 @@ describe('AuthController grant flows', () => {
|
||||
expect(res.body).toEqual({});
|
||||
});
|
||||
|
||||
it('grant-user-app: grants read and write share delegations on a region, never on the namespace', async () => {
|
||||
const appName = `tkvl-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestKvShareLevelsApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
for (const root of ['kv-share-read', 'kv-share-write']) {
|
||||
const namespace = `manage:${root}:${issuer.uuid}:${app.uid}`;
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: namespace },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ legacyCode: 'invalid_kv_share_prefix' });
|
||||
|
||||
const res = makeRes();
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: `${namespace}:team` },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({});
|
||||
}
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT `permission` FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ? ORDER BY `permission`',
|
||||
[issuer.id, app.id],
|
||||
);
|
||||
expect(rows.map((r: { permission: string }) => r.permission)).toEqual([
|
||||
`manage:kv-share-read:${issuer.uuid}:${app.uid}:team`,
|
||||
`manage:kv-share-write:${issuer.uuid}:${app.uid}:team`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('grant-user-app: refuses a share delegation whose region a new share could not name, writing nothing', async () => {
|
||||
const appName = `tkvc-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestKvShareCharsApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
for (const region of ['Team', 'team:Abc', 'caf\u00E9', 'a b'])
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{
|
||||
app_uid: app.uid,
|
||||
permission: `manage:kv-share-read:${issuer.uuid}:${app.uid}:${region}`,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ legacyCode: 'invalid_kv_share_prefix' });
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT `permission` FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?',
|
||||
[issuer.id, app.id],
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('grant-user-app: refuses a permission outside the app-grantable families, writing nothing', async () => {
|
||||
const appName = `tng-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestNotGrantableApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const other = await server.stores.app.create(
|
||||
{
|
||||
name: `tng-other-${uuidv4()}`,
|
||||
title: 'TestNotGrantableOther',
|
||||
index_url: `https://tng-other-${uuidv4()}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const refused = [
|
||||
['driver:puter-image-generation:generate'],
|
||||
[`user:${target.uuid}:email:read`],
|
||||
// One bad entry refuses the batch.
|
||||
['events:background', 'random-string'],
|
||||
// A share delegation names the app's own namespace only.
|
||||
[`manage:kv-share:${issuer.uuid}:${other.uid}:rooms`],
|
||||
];
|
||||
for (const permissions of refused) {
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permissions },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'permission_not_grantable',
|
||||
});
|
||||
}
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT 1 FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?',
|
||||
[issuer.id, app.id],
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('grant-user-app: refuses a non-canonical spelling of a grantable permission', async () => {
|
||||
const appName = `tnc-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestNonCanonicalApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
for (const permission of [
|
||||
`f\\s:/${issuer.username}/Documents:read`,
|
||||
`app-root-di\\r:${app.uid}:read`,
|
||||
`manag\\e:kv-share:${issuer.uuid}:${app.uid}:rooms`,
|
||||
]) {
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ legacyCode: 'permission_not_grantable' });
|
||||
}
|
||||
});
|
||||
|
||||
it('grant-user-app: refuses a share delegation over a prefix no share could be made of, as a share would be', async () => {
|
||||
const appName = `tkvp-${uuidv4().slice(0, 8)}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestKvSharePrefixApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
for (const root of ['kv-share', 'kv-share-read', 'kv-share-write']) {
|
||||
// A share grant one character wider than the column.
|
||||
const head = `${root}:${issuer.uuid}:${app.uid}:`;
|
||||
const region = 'a'.repeat(256 - head.length);
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{
|
||||
app_uid: app.uid,
|
||||
permission: `manage:${head}${region}`,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ legacyCode: 'invalid_kv_share_prefix' });
|
||||
}
|
||||
});
|
||||
|
||||
it('grant-user-app: a share delegation must name the app the request names, by uid, name or origin', async () => {
|
||||
const appName = `tkvn-${uuidv4()}`;
|
||||
const origin = `https://${appName}.example.test`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestKvShareNamedApp',
|
||||
index_url: `${origin}/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const other = await server.stores.app.create(
|
||||
{
|
||||
name: `tkvn-other-${uuidv4()}`,
|
||||
title: 'TestKvShareNamedOther',
|
||||
index_url: `https://tkvn-other-${uuidv4()}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const grant = (requester: Record<string, string>, namespace: string) =>
|
||||
it('grant-user-app: 400 on a `permission` wider than the column it lands in', async () => {
|
||||
// 300 chars is under the 4096 input cap but over `varchar(255)`, so it
|
||||
// used to reach the INSERT and fault on MySQL/Postgres. The check runs
|
||||
// after the rewrite, so this has to be a permission no rewriter
|
||||
// shortens — and it has to reject before the app is resolved, since
|
||||
// this uid names no app.
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{
|
||||
...requester,
|
||||
permission: `manage:kv-share:${issuer.uuid}:${namespace}:rooms`,
|
||||
app_uid: `app-${uuidv4()}`,
|
||||
permission: `service:${'a'.repeat(300)}:ii:read`,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
);
|
||||
|
||||
for (const requester of [{ app_uid: app.name }, { origin }]) {
|
||||
await grant(requester, app.uid);
|
||||
await expect(grant(requester, other.uid)).rejects.toMatchObject({
|
||||
legacyCode: 'permission_not_grantable',
|
||||
});
|
||||
}
|
||||
// The app's name is not its namespace.
|
||||
await expect(
|
||||
grant({ app_uid: app.uid }, app.name),
|
||||
).rejects.toMatchObject({ legacyCode: 'permission_not_grantable' });
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT `permission` FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?',
|
||||
[issuer.id, app.id],
|
||||
);
|
||||
expect(rows.map((r: { permission: string }) => r.permission)).toEqual([
|
||||
`manage:kv-share:${issuer.uuid}:${app.uid}:rooms`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('grant-user-app: 400 on a `permission` wider than the column it lands in', async () => {
|
||||
// Under the 4096 input cap but over `varchar(255)`, so it would reach
|
||||
// the INSERT and fault on MySQL/Postgres. The check runs after the
|
||||
// rewrite, so this has to be a grantable permission no rewriter
|
||||
// shortens.
|
||||
const appName = `tw-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName,
|
||||
title: 'TestWidthApp',
|
||||
index_url: `https://${appName}.example.test/index.html`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
// The shares it delegates fit, at 255 characters; the consent's own
|
||||
// `manage:` arm takes it past the column.
|
||||
const head = `kv-share:${issuer.uuid}:${app.uid}:`;
|
||||
const permission = `manage:${head}${'a'.repeat(255 - head.length)}`;
|
||||
await expect(
|
||||
inCtx(issuerActor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: 'Invalid `permission`',
|
||||
});
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('grant-user-app: an oversized permission that a rewriter shortens is accepted', async () => {
|
||||
@@ -2888,8 +2635,6 @@ describe('AuthController grant flows', () => {
|
||||
// on "please try again" for any deeply nested file. Exercised here
|
||||
// through a rewriter of our own, since the real fs rewriter resolves
|
||||
// the path through the fsentry store and this suite has no entries.
|
||||
// It rewrites a share delegation only because that is a grantable
|
||||
// shape no production rewriter touches.
|
||||
const appName = `tlp-${uuidv4()}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
@@ -2900,15 +2645,13 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
|
||||
// Over the column only by its `manage:` arm, so the shares it
|
||||
// delegates still fit.
|
||||
const head = `kv-share:${issuer.uuid}:${app.uid}:`;
|
||||
const longPermission = `manage:${head}${'a'.repeat(255 - head.length)}`;
|
||||
const shortPermission = `manage:${head}${uuidv4()}`;
|
||||
const prefix = `tlprw-${uuidv4()}`;
|
||||
const longPermission = `${prefix}:${'a'.repeat(300)}:read`;
|
||||
const shortPermission = `${prefix}:${uuidv4()}:read`;
|
||||
expect(longPermission.length).toBeGreaterThan(255);
|
||||
expect(shortPermission.length).toBeLessThanOrEqual(255);
|
||||
server.services.permission.registerRewriter({
|
||||
id: `test-shorten-${app.uid}`,
|
||||
id: `test-shorten-${prefix}`,
|
||||
matches: (permission: string) => permission === longPermission,
|
||||
rewrite: async () => shortPermission,
|
||||
});
|
||||
@@ -2968,8 +2711,7 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
|
||||
// A grantable shape no production rewriter touches.
|
||||
const prefix = `manage:kv-share:${issuer.uuid}:${app.uid}:conc-${uuidv4()}`;
|
||||
const prefix = `conc-${uuidv4()}`;
|
||||
let release: () => void;
|
||||
let entered: () => void;
|
||||
const held = new Promise<void>((r) => {
|
||||
@@ -2997,7 +2739,7 @@ describe('AuthController grant flows', () => {
|
||||
makeReq(
|
||||
{
|
||||
app_uid: app.uid,
|
||||
permission: `${prefix}:write`,
|
||||
permission: `${prefix}:${app.uid}:write`,
|
||||
},
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
@@ -3038,8 +2780,8 @@ describe('AuthController grant flows', () => {
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
|
||||
const prefix = `manage:kv-share:${issuer.uuid}:${app.uid}:tardrw-${uuidv4()}`;
|
||||
const pseudoPermission = `${prefix}:write`;
|
||||
const prefix = `tardrw-${uuidv4()}`;
|
||||
const pseudoPermission = `${prefix}:${app.uid}:write`;
|
||||
const resolvedPermission = `fs:${uuidv4()}:write`;
|
||||
const NOTHING = 'nothing-in-particular';
|
||||
server.services.permission.registerRewriter({
|
||||
@@ -3280,7 +3022,7 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
// phase A's pre-flight passes, phase C's re-check then fails, so the
|
||||
// leaf (and its auto-created parent) already exist by the time the
|
||||
// request unwinds.
|
||||
const prefix = `manage:kv-share:${user.uuid}:${appUid}:rbparent-${uuidv4()}`;
|
||||
const prefix = `rbparent-${uuidv4()}`;
|
||||
let calls = 0;
|
||||
server.services.permission.registerRewriter({
|
||||
id: prefix,
|
||||
@@ -3446,13 +3188,13 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
|
||||
});
|
||||
|
||||
it('refuses a `manage:` grant outright, creating nothing', async () => {
|
||||
it('does not apply `create` to a `manage:` grant — 404 unchanged, nothing created', async () => {
|
||||
const p = path(`manage-missing-${rand()}`);
|
||||
await expect(
|
||||
grant({ permission: `manage:fs:${p}:write` }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'permission_not_grantable',
|
||||
statusCode: 404,
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
expect(await server.stores.fsEntry.getEntryByPath(p)).toBeFalsy();
|
||||
});
|
||||
@@ -3542,7 +3284,7 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
// (so nothing is rejected before anything is created) but has grown
|
||||
// too wide by the time phase C re-runs it — modelling a grant that
|
||||
// only fails once the fs path from the same batch already exists.
|
||||
const prefix = `manage:kv-share:${user.uuid}:${appUid}:rbtest-${uuidv4()}`;
|
||||
const prefix = `rbtest-${uuidv4()}`;
|
||||
let calls = 0;
|
||||
server.services.permission.registerRewriter({
|
||||
id: `test-rollback-${prefix}`,
|
||||
@@ -3583,37 +3325,6 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('check-permissions answers an app asking about itself through ACL, so a folder grant covers what is inside', async () => {
|
||||
const dir = path(`acl-self-${rand()}`);
|
||||
const child = `${dir}/inner`;
|
||||
await server.services.fs.mkdir(user.id, {
|
||||
path: child,
|
||||
createMissingParents: true,
|
||||
});
|
||||
expect(await checkAsApp(`fs:${child}:read`)).toBe(false);
|
||||
|
||||
await grant({ permission: `fs:${dir}:write`, create: false });
|
||||
expect(await checkAsApp(`fs:${child}:read`)).toBe(true);
|
||||
expect(await checkAsApp(`fs:${child}:write`)).toBe(true);
|
||||
// Another user's home stays out of reach.
|
||||
expect(await checkAsApp(`fs:/${target.username}/Documents:read`)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('check-permissions answers an app asking about itself through ACL for a uid too', async () => {
|
||||
const dir = path(`acl-self-uid-${rand()}`);
|
||||
const child = await server.services.fs.mkdir(user.id, {
|
||||
path: `${dir}/inner`,
|
||||
createMissingParents: true,
|
||||
});
|
||||
expect(await checkAsApp(`fs:${child.uuid}:read`)).toBe(false);
|
||||
|
||||
await grant({ permission: `fs:${dir}:read`, create: false });
|
||||
expect(await checkAsApp(`fs:${child.uuid}:read`)).toBe(true);
|
||||
expect(await checkAsApp(`fs:${child.uuid}:write`)).toBe(false);
|
||||
});
|
||||
|
||||
it('check-permissions on a missing fs path answers false rather than 404ing, without poisoning a mixed batch', async () => {
|
||||
const missingPath = path(`.checkmissing-${rand()}`);
|
||||
// A held fs grant: the owning user holds it implicitly (is-owner), so
|
||||
@@ -4440,9 +4151,9 @@ describe('AuthController.handleCreateAccessToken + handleRevokeAccessToken', ()
|
||||
['an oversized permission string', ['x'.repeat(4097)]],
|
||||
[
|
||||
'an oversized `extra`',
|
||||
[['events:background', { a: 'y'.repeat(5000) }]],
|
||||
[['service:x:ii:read', { a: 'y'.repeat(5000) }]],
|
||||
],
|
||||
['a non-object `extra`', [['events:background', 'nope']]],
|
||||
['a non-object `extra`', [['service:x:ii:read', 'nope']]],
|
||||
['an empty permission string', ['']],
|
||||
])('rejects %s with 400', async (_label, permissions) => {
|
||||
await expect(
|
||||
@@ -6603,25 +6314,29 @@ 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 });
|
||||
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();
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
// `system` is seeded by migration; `admin` only by the self-hosted boot.
|
||||
@@ -7010,9 +6725,16 @@ 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 });
|
||||
installSeatStub(server as never, [seat.id]);
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.handleChangeEmail(
|
||||
@@ -7028,9 +6750,17 @@ 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 });
|
||||
installSeatStub(server as never, [seat.id]);
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.handleChangeUsername(
|
||||
@@ -7653,88 +7383,6 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
|
||||
]);
|
||||
});
|
||||
|
||||
it('check-permissions: answers false for share grants, which name what a holder is never told, but not for their manage arm', async () => {
|
||||
const { user: owner, actor: ownerActor } = await makeUserAndActor();
|
||||
const { user: holder, actor: holderActor } = await makeUserAndActor();
|
||||
const region = `notes-${uuidv4().slice(0, 8)}`;
|
||||
const grant = `kv-share-read:${owner.uuid}:os-global:${region}`;
|
||||
await inCtx(ownerActor, () =>
|
||||
server.services.permission.grantUserUserPermission(
|
||||
ownerActor,
|
||||
holder.username,
|
||||
grant,
|
||||
),
|
||||
);
|
||||
expect(await server.services.permission.check(holderActor, grant)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const asked = [
|
||||
grant,
|
||||
`kv-share:${owner.uuid}:os-global:${region}:inner`,
|
||||
`kv-share-write:${owner.uuid}:os-global:${region}`,
|
||||
];
|
||||
const res = makeRes();
|
||||
await inCtx(holderActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq({ permissions: asked }, { actor: holderActor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
permissions: Object.fromEntries(asked.map((p) => [p, false])),
|
||||
});
|
||||
|
||||
// What `puter.perms.check('kvShare', …)` asks.
|
||||
const manage = `manage:kv-share-read:${owner.uuid}:os-global:${region}`;
|
||||
const ownerRes = makeRes();
|
||||
await inCtx(ownerActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq({ permissions: [manage] }, { actor: ownerActor }),
|
||||
ownerRes,
|
||||
),
|
||||
);
|
||||
expect(ownerRes.body).toEqual({ permissions: { [manage]: true } });
|
||||
});
|
||||
|
||||
it('check-permissions: answers false for share grants asked by an app actor too', async () => {
|
||||
const { user: owner, actor: ownerActor } = await makeUserAndActor();
|
||||
const { user: holder, actor: holderActor } = await makeUserAndActor();
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `cpk-${uuidv4()}`,
|
||||
title: 'TestCheckPermsKvApp',
|
||||
index_url: 'https://check-perms-kv.example.test/index.html',
|
||||
},
|
||||
{ ownerUserId: holder.id },
|
||||
);
|
||||
const grant = `kv-share-read:${owner.uuid}:${app.uid}:notes-${uuidv4().slice(0, 8)}`;
|
||||
await inCtx(ownerActor, () =>
|
||||
server.services.permission.grantUserUserPermission(
|
||||
ownerActor,
|
||||
holder.username,
|
||||
grant,
|
||||
),
|
||||
);
|
||||
const appActor = makeActor({
|
||||
user: holderActor.user,
|
||||
app: { id: app.id, uid: app.uid },
|
||||
});
|
||||
// The holder's app in the share's namespace holds it.
|
||||
expect(await server.services.permission.check(appActor, grant)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const res = makeRes();
|
||||
await inCtx(appActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq({ permissions: [grant] }, { actor: appActor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({ permissions: { [grant]: false } });
|
||||
});
|
||||
|
||||
// What lets a permission request settle without a prompt. The second string
|
||||
// is a consent scope nothing implies, so the first's `true` is the grant.
|
||||
it('check-permissions: an app-under-user actor sees a grant made to that app', async () => {
|
||||
@@ -7949,7 +7597,7 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const permission = 'events:background';
|
||||
const permission = 'service:tl-app:ii:read';
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq({ app_uid: app.uid, permission, extra: {} }, { actor }),
|
||||
@@ -8506,10 +8154,15 @@ 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 { 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 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 actor = {
|
||||
user: {
|
||||
id: seat!.id,
|
||||
@@ -8530,10 +8183,15 @@ describe('AuthController 2FA flows', () => {
|
||||
});
|
||||
|
||||
it('refuses to disable 2FA while the team requires it', async () => {
|
||||
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 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 actor = {
|
||||
user: {
|
||||
id: seat!.id,
|
||||
@@ -8544,6 +8202,9 @@ 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()),
|
||||
@@ -8834,9 +8495,16 @@ 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 });
|
||||
installSeatStub(server as never, [seat.id]);
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()),
|
||||
@@ -10585,7 +10253,7 @@ describe('AuthController — app-data grants', () => {
|
||||
await expect(
|
||||
post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permission: `app-data:app-${uuidv4()}:kv:read`,
|
||||
permission: 'app-data:app-nope/:kv:read',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
@@ -10652,7 +10320,7 @@ describe('AuthController — app-data grants', () => {
|
||||
|
||||
it('leaves unrelated permissions untouched by the new validation', async () => {
|
||||
const grantee = await makeAppRow();
|
||||
const permission = 'events:background';
|
||||
const permission = 'service:unrelated:ii:read';
|
||||
await post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permission,
|
||||
@@ -10666,9 +10334,17 @@ 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 });
|
||||
installSeatStub(server as never, [seat.id]);
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
await server.stores.user.update(seat.id, {
|
||||
password: await bcrypt.hash('temporary-password', 8),
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
hasVerifiedPhone,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { isAppActor, isPlainUserActor, makeActor } from '../../core/actor.js';
|
||||
import { isPlainUserActor, makeActor } from '../../core/actor.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
consumeRouteRateLimit,
|
||||
@@ -88,16 +88,13 @@ import {
|
||||
import { promoteToVerifiedGroup } from '../../util/userProvisioning.js';
|
||||
import {
|
||||
assertBoundedManageGrant,
|
||||
assertNewShareSegments,
|
||||
assertPrefixFitsAccess,
|
||||
isKvSharePermission,
|
||||
} from '../../services/kvShare/kvShares.js';
|
||||
} from '../../services/events/kvShares.js';
|
||||
import {
|
||||
APP_DATA_PERMISSION_PREFIX,
|
||||
appDataSharingAllowed,
|
||||
parseAppDataPermission,
|
||||
} from '../../services/permission/appDataScopes.js';
|
||||
import { parseAppGrantablePermission } from '../../services/permission/appGrantable.js';
|
||||
import {
|
||||
assertCreatablePath,
|
||||
isOwnHomePath,
|
||||
@@ -114,7 +111,6 @@ 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
|
||||
@@ -599,7 +595,6 @@ export class AuthController extends PuterController {
|
||||
user_uid: string;
|
||||
purpose: string;
|
||||
auth_id?: string;
|
||||
oidcContinuation?: unknown;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid token.', {
|
||||
@@ -633,7 +628,7 @@ export class AuthController extends PuterController {
|
||||
|
||||
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
|
||||
|
||||
await this.#completeLogin(req, res, user, decoded.oidcContinuation);
|
||||
await this.#completeLogin(req, res, user);
|
||||
}
|
||||
|
||||
// -- Login: recovery code ----------------------------------------
|
||||
@@ -669,7 +664,6 @@ export class AuthController extends PuterController {
|
||||
user_uid: string;
|
||||
purpose: string;
|
||||
auth_id?: string;
|
||||
oidcContinuation?: unknown;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid token.', {
|
||||
@@ -694,11 +688,6 @@ 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(',')
|
||||
@@ -711,15 +700,15 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
await this.#consumeOtpLoginToken(token);
|
||||
|
||||
if (!(await this.stores.user.consumeOtpRecoveryCode(user.id, hashed))) {
|
||||
await this.#recordSecondFactorFailure(user.uuid);
|
||||
res.json({ proceed: false });
|
||||
return;
|
||||
}
|
||||
// Consume the recovery code
|
||||
codes.splice(idx, 1);
|
||||
await this.stores.user.update(user.id, {
|
||||
otp_recovery_codes: codes.join(','),
|
||||
});
|
||||
|
||||
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
|
||||
|
||||
await this.#completeLogin(req, res, user, decoded.oidcContinuation);
|
||||
await this.#completeLogin(req, res, user);
|
||||
}
|
||||
|
||||
// -- Signup ------------------------------------------------------
|
||||
@@ -1943,7 +1932,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;
|
||||
}
|
||||
@@ -2177,8 +2166,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);
|
||||
});
|
||||
|
||||
@@ -2233,8 +2222,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);
|
||||
});
|
||||
|
||||
@@ -2268,7 +2257,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.',
|
||||
@@ -2402,7 +2391,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.',
|
||||
@@ -3000,45 +2989,6 @@ export class AuthController extends PuterController {
|
||||
return typeof permission === 'string' && permission ? [permission] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses any entry outside the families an app can be granted. Anything
|
||||
* else is a row the backend never checks for an app, or one wider than the
|
||||
* consent prompt can describe.
|
||||
*/
|
||||
async #assertAppGrantable(
|
||||
actor: Actor,
|
||||
appIdentifier: string,
|
||||
list: string[],
|
||||
): Promise<void> {
|
||||
const refuse = (entry: string) =>
|
||||
new HttpError(
|
||||
400,
|
||||
`Permission cannot be granted to an app: ${entry}`,
|
||||
{ legacyCode: 'permission_not_grantable' },
|
||||
);
|
||||
let appUid: string | undefined;
|
||||
for (const entry of list) {
|
||||
// A delegation over a whole key-value namespace has no bounded
|
||||
// description; refused with its own code.
|
||||
assertBoundedManageGrant(entry);
|
||||
const parsed = parseAppGrantablePermission(entry, actor.user!.uuid);
|
||||
if (!parsed) throw refuse(entry);
|
||||
if (parsed.family !== 'kvShare') continue;
|
||||
// Held to what a new share's prefix may be, so a consent never
|
||||
// covers shares that could not be made.
|
||||
assertNewShareSegments(parsed.segments);
|
||||
assertPrefixFitsAccess(
|
||||
parsed.ownerUuid,
|
||||
parsed.appUid,
|
||||
parsed.segments.join(':'),
|
||||
parsed.level,
|
||||
);
|
||||
// An app shares regions of its own namespace only.
|
||||
appUid ??= (await this.stores.app.resolveApp(appIdentifier))?.uid;
|
||||
if (parsed.appUid !== appUid) throw refuse(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a cross-app data grant: the target must exist, must not have opted
|
||||
* out of sharing, and must be named. Also creates the target's AppData
|
||||
@@ -3217,7 +3167,6 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
// 400 on a bad value, before anything else runs. Absent means create.
|
||||
const create = parseCreateFlag(req.body?.create);
|
||||
await this.#assertAppGrantable(req.actor!, app_uid, list);
|
||||
|
||||
// Validate every entry before writing or creating anything, so a bad
|
||||
// one in the list cannot leave a partially-granted set behind: the
|
||||
@@ -3230,6 +3179,10 @@ export class AuthController extends PuterController {
|
||||
kind: FsCreateKind;
|
||||
}> = [];
|
||||
for (const entry of list) {
|
||||
// A delegation over a whole key-value namespace has no bounded
|
||||
// description, so it is refused where it is asked for rather than
|
||||
// prompted for and then refused at use.
|
||||
assertBoundedManageGrant(entry);
|
||||
await this.#prepareAppDataGrant(req.actor!, entry);
|
||||
const plan = await this.#planFsPathCreate(
|
||||
req.actor!,
|
||||
@@ -3436,28 +3389,11 @@ export class AuthController extends PuterController {
|
||||
const unique = [...new Set(permissions)] as string[];
|
||||
const result: Record<string, boolean> = {};
|
||||
|
||||
// A share grant names its owner and prefix, which its holder is never
|
||||
// told, so asking about one would confirm a guess. `manage:` arms are
|
||||
// the app's own consent and stay answerable.
|
||||
const answerable: string[] = [];
|
||||
for (const permission of unique) {
|
||||
if (
|
||||
typeof permission === 'string' &&
|
||||
isKvSharePermission(permission)
|
||||
)
|
||||
result[permission] = false;
|
||||
else answerable.push(permission);
|
||||
}
|
||||
|
||||
// `fs:` reaches down a tree, and only ACL walks that. An app asking
|
||||
// about itself gets the same answer, so a grant on a parent folder
|
||||
// doesn't read as missing and prompt again.
|
||||
// `fs:` reaches down a tree, and only ACL walks that.
|
||||
const viaAcl = new Set(
|
||||
app_uid || isAppActor(actor)
|
||||
? answerable.filter((p) => aclAnswerableFsPermission(p))
|
||||
: [],
|
||||
app_uid ? unique.filter((p) => aclAnswerableFsPermission(p)) : [],
|
||||
);
|
||||
const viaScan = answerable.filter((p) => !viaAcl.has(p));
|
||||
const viaScan = unique.filter((p) => !viaAcl.has(p));
|
||||
|
||||
let granted: Map<string, boolean>;
|
||||
try {
|
||||
@@ -4186,7 +4122,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,
|
||||
@@ -4260,7 +4196,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,
|
||||
@@ -4513,7 +4449,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.',
|
||||
@@ -4523,7 +4459,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.',
|
||||
@@ -4532,7 +4468,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.',
|
||||
@@ -4685,15 +4621,7 @@ 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,
|
||||
@@ -4719,7 +4647,6 @@ 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 { installSeatStub } from '../../testFixtures/seatStub.js';
|
||||
import type { IConfig } from '../../types.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();
|
||||
env = await setupPuterTestEnv({ teams_enabled: true } as IConfig);
|
||||
vi.spyOn(env.server.clients.email, 'send').mockImplementation(
|
||||
async (to, template, values) => {
|
||||
if (template === 'email_verification_link')
|
||||
@@ -333,8 +333,15 @@ 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);
|
||||
installSeatStub(env.server as never, [member.id], {
|
||||
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, {
|
||||
require2fa: true,
|
||||
});
|
||||
const proof = env.server.services.token.sign('oidc-state', {
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
describe('recovery code consumption', () => {
|
||||
let env: PuterTestEnv;
|
||||
const password = 'recovery-code-password';
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Hold the same cached snapshot until every concurrent request has read it.
|
||||
const overlapReads = (userUuid: string, count: number) => {
|
||||
const redis = env.server.clients.redis;
|
||||
const get = redis.get.bind(redis);
|
||||
const ready = Promise.withResolvers<void>();
|
||||
let reads = 0;
|
||||
vi.spyOn(redis, 'get').mockImplementation(async (key: string) => {
|
||||
const value = await get(key);
|
||||
if (key.includes(userUuid) && reads < count) {
|
||||
if (++reads === count) ready.resolve();
|
||||
await ready.promise;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
};
|
||||
|
||||
const post = async (path: string, body: Record<string, unknown>) => {
|
||||
const response = await fetch(new URL(path, env.origin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { response, body: await response.json() };
|
||||
};
|
||||
|
||||
const account = async (codes: string[]) => {
|
||||
const username = `recovery_${uuidv4().slice(0, 8)}`;
|
||||
await createTestUser(env.server, { username, password });
|
||||
const user = (await env.server.stores.user.getByUsername(username))!;
|
||||
await env.server.stores.user.update(user.id, {
|
||||
otp_enabled: true,
|
||||
otp_secret: 'TESTSECRETBASE32',
|
||||
otp_recovery_codes: codes.map(hashRecoveryCode).join(','),
|
||||
});
|
||||
const challenge = async () => {
|
||||
const { response, body } = await post('/login', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
expect(response.status).toBe(202);
|
||||
expect(body.next_step).toBe('otp');
|
||||
return body.otp_jwt_token as string;
|
||||
};
|
||||
return { user, challenge };
|
||||
};
|
||||
|
||||
it('allows only one concurrent redemption of the same code', async () => {
|
||||
const { user, challenge } = await account(['RECOVER1', 'RECOVER2']);
|
||||
const tokens = await Promise.all([challenge(), challenge()]);
|
||||
overlapReads(user.uuid, tokens.length);
|
||||
const results = await Promise.all(
|
||||
tokens.map((token) =>
|
||||
post('/login/recovery-code', { token, code: 'RECOVER1' }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
results.filter((result) => result.body.next_step === 'complete'),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
results.filter((result) => result.body.proceed === false),
|
||||
).toHaveLength(1);
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.otp_recovery_codes).toBe(hashRecoveryCode('RECOVER2'));
|
||||
});
|
||||
|
||||
it('consumes different concurrent codes without restoring either one', async () => {
|
||||
const codes = ['RECOVER1', 'RECOVER2', 'RECOVER3'];
|
||||
const { user, challenge } = await account(codes);
|
||||
const tokens = await Promise.all(codes.map(() => challenge()));
|
||||
overlapReads(user.uuid, tokens.length);
|
||||
const results = await Promise.all(
|
||||
codes.map((code, index) =>
|
||||
post('/login/recovery-code', { token: tokens[index], code }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
results.every((result) => result.body.next_step === 'complete'),
|
||||
).toBe(true);
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.otp_recovery_codes).toBe('');
|
||||
const replay = await post('/login/recovery-code', {
|
||||
token: tokens[0],
|
||||
code: codes[0],
|
||||
});
|
||||
expect(replay.body).toMatchObject({ proceed: false });
|
||||
});
|
||||
|
||||
it.each([{ code: 123456 }, { code: {} }, { code: [] }])(
|
||||
'rejects a non-string recovery code $code without consuming a code',
|
||||
async ({ code }) => {
|
||||
const { user, challenge } = await account(['RECOVER1']);
|
||||
const result = await post('/login/recovery-code', {
|
||||
token: await challenge(),
|
||||
code,
|
||||
});
|
||||
expect(result.response.status).toBe(400);
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
}))!.otp_recovery_codes,
|
||||
).toBe(hashRecoveryCode('RECOVER1'));
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -154,9 +154,8 @@ export class EventsController extends PuterController {
|
||||
// -- Cross-user key-value handles --------------------------------
|
||||
|
||||
/**
|
||||
* POST /events/kv-handles — hand another user a region of this account's
|
||||
* key-value data, `watch` unless the body asks for more. The older name for
|
||||
* `POST /kv/shares`, keeping its own body and response shapes.
|
||||
* POST /events/kv-handles — hand another user a watchable region of this
|
||||
* account's key-value data.
|
||||
*
|
||||
* An account session, or an app session holding a `manage:` delegation on
|
||||
* the region — the service tells those apart and gates each rather than the
|
||||
@@ -168,32 +167,12 @@ export class EventsController extends PuterController {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
})
|
||||
async mintKvHandle(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = bodyRecord(req);
|
||||
const share = await this.services.kvShare.mintKvHandle(
|
||||
actor,
|
||||
{
|
||||
prefix: body.prefix,
|
||||
recipient: {
|
||||
username:
|
||||
typeof body.granteeUsername === 'string'
|
||||
? body.granteeUsername
|
||||
: undefined,
|
||||
uid:
|
||||
typeof body.granteeUid === 'string'
|
||||
? body.granteeUid
|
||||
: undefined,
|
||||
},
|
||||
access: body.access,
|
||||
appUid: body.appUid,
|
||||
},
|
||||
// Callers of this route mint again to get the same handle back.
|
||||
{ defaultAccess: 'watch', keepLevelUnlessNamed: true },
|
||||
res.json(
|
||||
await this.services.events.mintKvHandle(actor, bodyRecord(req)),
|
||||
);
|
||||
res.json({ handle: share.handle, prefix: share.prefix });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,7 +189,7 @@ export class EventsController extends PuterController {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
|
||||
const page = await this.services.kvShare.listKvHandles(actor, {
|
||||
const page = await this.services.events.listKvHandles(actor, {
|
||||
limit: normalizeLimit(query.limit, {
|
||||
cap: KV_HANDLE_LIST_LIMIT_CAP,
|
||||
}),
|
||||
@@ -219,10 +198,7 @@ export class EventsController extends PuterController {
|
||||
});
|
||||
|
||||
res.json({
|
||||
items: page.items.map(({ holderUsername, ...row }) => ({
|
||||
...row,
|
||||
granteeUsername: holderUsername,
|
||||
})),
|
||||
items: page.items,
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total !== undefined ? { total: page.total } : {}),
|
||||
});
|
||||
@@ -241,10 +217,7 @@ export class EventsController extends PuterController {
|
||||
async revokeKvHandle(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.kvShare.revokeKvHandle(
|
||||
actor,
|
||||
req.params.handle,
|
||||
),
|
||||
await this.services.events.revokeKvHandle(actor, req.params.handle),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -163,16 +163,6 @@ export const EVENTS_ACK_LIMIT = userWindow('events:ack', 600);
|
||||
*/
|
||||
export const EVENTS_KV_HANDLE_LIMIT = userWindow('events:kvHandles', 60);
|
||||
|
||||
/**
|
||||
* `kv.share.received` notices per hour, per owner and holder. A share past it
|
||||
* is still made, without a notice.
|
||||
*/
|
||||
export const KV_SHARE_NOTICE_LIMIT = userWindow(
|
||||
'kvShare:notices',
|
||||
5,
|
||||
60 * 60_000,
|
||||
);
|
||||
|
||||
/**
|
||||
* Live share handles one account may hold out at a time, across every app.
|
||||
*
|
||||
|
||||
@@ -1948,27 +1948,6 @@ 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,7 +83,6 @@ import {
|
||||
loadLegacyAssociatedApps,
|
||||
replacedByMove,
|
||||
resolveOrNotFound,
|
||||
resolveReadRange,
|
||||
signEntryThumbnail,
|
||||
} from './legacyFsHelpers.js';
|
||||
import type {
|
||||
@@ -1496,7 +1495,10 @@ export class FSController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
const range = resolveReadRange(req.headers.range, query);
|
||||
const range =
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
|
||||
@@ -3732,63 +3732,6 @@ 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,7 +17,6 @@
|
||||
* 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';
|
||||
@@ -103,7 +102,6 @@ import {
|
||||
type GuiItemEventName,
|
||||
loadLegacyAssociatedApps,
|
||||
replacedByMove,
|
||||
resolveReadRange,
|
||||
resolveV1Selector,
|
||||
signEntry,
|
||||
signingConfigFromAppConfig,
|
||||
@@ -1289,7 +1287,10 @@ export class LegacyFSController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const range = resolveReadRange(req.headers.range, query);
|
||||
const range =
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
@@ -1347,7 +1348,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 as Team2faLookup);
|
||||
await assertTeam2fa(actor!.user, this.stores.team);
|
||||
|
||||
req.actor = assertResolvedActor(actor!);
|
||||
Context.set('actor', actor);
|
||||
@@ -1802,7 +1803,10 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
// File: stream bytes with Range support.
|
||||
const range = resolveReadRange(req.headers.range, query);
|
||||
const range =
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
getBoolean,
|
||||
getString,
|
||||
loadLegacyAssociatedApps,
|
||||
resolveReadRange,
|
||||
signEntry,
|
||||
signEntryThumbnail,
|
||||
signingConfigFromAppConfig,
|
||||
@@ -619,89 +618,3 @@ 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,57 +99,6 @@ export function getBoolean(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Non-negative safe integer from a number or a plain digit string; anything
|
||||
// else (exponents, hex, whitespace) is rejected.
|
||||
function parseByteParam(raw: unknown, name: string, min: number): number {
|
||||
const value =
|
||||
typeof raw === 'number'
|
||||
? raw
|
||||
: typeof raw === 'string' && /^\d+$/.test(raw)
|
||||
? Number(raw)
|
||||
: NaN;
|
||||
if (!Number.isSafeInteger(value) || value < min) {
|
||||
throw new HttpError(400, `Invalid ${name}`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an HTTP Range header string from explicit `headersRange` or fallback
|
||||
* `offset` / `byte_count` query parameters.
|
||||
*/
|
||||
export function resolveReadRange(
|
||||
headersRange: unknown,
|
||||
query: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
if (typeof headersRange === 'string' && headersRange.length > 0) {
|
||||
return headersRange;
|
||||
}
|
||||
const isSet = (v: unknown) => v !== undefined && v !== null && v !== '';
|
||||
const offset = isSet(query.offset)
|
||||
? parseByteParam(query.offset, 'offset', 0)
|
||||
: undefined;
|
||||
const byteCount = isSet(query.byte_count)
|
||||
? parseByteParam(query.byte_count, 'byte_count', 1)
|
||||
: undefined;
|
||||
|
||||
if (byteCount !== undefined) {
|
||||
const last = (offset ?? 0) + byteCount - 1;
|
||||
if (!Number.isSafeInteger(last)) {
|
||||
throw new HttpError(400, 'Invalid byte_count', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return `bytes=${offset ?? 0}-${last}`;
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
return `bytes=${offset}-`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Accepts either `{ path }` or `{ uid }` or `{ id }` or `{ parent, name }`
|
||||
// from a legacy body field. Returns a resolved entry (throwing 404 if not
|
||||
// found or 400 if no usable ref is present).
|
||||
|
||||
@@ -27,12 +27,12 @@ import { EventsController } from './events/EventsController.js';
|
||||
import { FSController } from './fs/FSController.js';
|
||||
import { HomepageController } from './homepage/HomepageController.js';
|
||||
import { HostingController } from './hosting/HostingController.js';
|
||||
import { KvShareController } from './kvShare/KvShareController.js';
|
||||
import { LegacyFSController } from './fs/LegacyFSController.js';
|
||||
import { NotificationController } from './notification/NotificationController.js';
|
||||
import { OIDCController } from './oidc/OIDCController.js';
|
||||
import { PuterAIController } from './puterai/PuterAIController.js';
|
||||
import { ShareController } from './share/ShareController.js';
|
||||
import { TeamController } from './team/TeamController.js';
|
||||
import { StaticAssetsController } from './static/StaticAssetsController.js';
|
||||
import { StaticPagesController } from './static/StaticPagesController.js';
|
||||
import { SystemController } from './system/SystemController.js';
|
||||
@@ -59,8 +59,8 @@ export const puterControllers = {
|
||||
broadcast: BroadcastController,
|
||||
notification: NotificationController,
|
||||
events: EventsController,
|
||||
kvShare: KvShareController,
|
||||
share: ShareController,
|
||||
team: TeamController,
|
||||
webdav: WebDAVController,
|
||||
oidc: OIDCController,
|
||||
wisp: WispController,
|
||||
|
||||
@@ -1,478 +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 { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
type TestUserCredentials,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { EVENTS_KV_HANDLE_LIMIT } from '../events/limits.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
|
||||
const call = (
|
||||
env: PuterTestEnv,
|
||||
method: string,
|
||||
path: string,
|
||||
token?: string,
|
||||
body?: unknown,
|
||||
) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
const resetBudgets = async (env: PuterTestEnv) => {
|
||||
for (const name of [env.users.user.username, env.users.other.username]) {
|
||||
const user = await env.server.stores.user.getByUsername(name);
|
||||
await env.server.clients.redis.del(
|
||||
`rate:${EVENTS_KV_HANDLE_LIMIT.scope}:${user!.id}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
describe('/kv/shares', () => {
|
||||
let env: PuterTestEnv;
|
||||
let ownerUuid: string;
|
||||
const req = (
|
||||
method: string,
|
||||
path: string,
|
||||
token?: string,
|
||||
body?: unknown,
|
||||
) => call(env, method, path, token, body);
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
events: { enabled: true, kvHandles: true },
|
||||
kv: { shareHandles: true },
|
||||
unlimitedMetering: true,
|
||||
} as IConfig);
|
||||
const owner = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
ownerUuid = owner!.uuid as string;
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetBudgets(env);
|
||||
});
|
||||
|
||||
const shareWithOther = async (body: Record<string, unknown> = {}) => {
|
||||
const res = await req('POST', '/kv/shares', env.users.user.token, {
|
||||
prefix: `http-${uuidv4().slice(0, 8)}:`,
|
||||
recipient: env.users.other.username,
|
||||
...body,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
it('shares at read by default and answers in the owner`s shape', async () => {
|
||||
const shared = await shareWithOther({ label: 'Groceries' });
|
||||
expect(Object.keys(shared).sort()).toEqual(
|
||||
[
|
||||
'access',
|
||||
'appUid',
|
||||
'createdAt',
|
||||
'handle',
|
||||
'holder',
|
||||
'label',
|
||||
'prefix',
|
||||
].sort(),
|
||||
);
|
||||
expect(shared).toMatchObject({
|
||||
access: 'read',
|
||||
appUid: null,
|
||||
holder: env.users.other.username,
|
||||
label: 'Groceries',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the recipient as an object too, and refuses an address', async () => {
|
||||
await shareWithOther({
|
||||
recipient: { username: env.users.other.username },
|
||||
access: 'write',
|
||||
});
|
||||
const refused = await req('POST', '/kv/shares', env.users.user.token, {
|
||||
prefix: 'address:',
|
||||
recipient: '[email protected]',
|
||||
});
|
||||
expect(refused.status).toBe(400);
|
||||
await expect(refused.json()).resolves.toMatchObject({
|
||||
code: 'invalid_recipient',
|
||||
});
|
||||
});
|
||||
|
||||
it('lists what was shared out, and what was shared with the holder without the owner', async () => {
|
||||
const shared = await shareWithOther({ access: 'write', label: 'Trip' });
|
||||
|
||||
const byMe = await req(
|
||||
'GET',
|
||||
'/kv/shares?limit=200',
|
||||
env.users.user.token,
|
||||
);
|
||||
const byMePage = (await byMe.json()) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(
|
||||
byMePage.items.find((row) => row.handle === shared.handle),
|
||||
).toEqual({
|
||||
handle: shared.handle,
|
||||
prefix: shared.prefix,
|
||||
access: 'write',
|
||||
appUid: null,
|
||||
holder: env.users.other.username,
|
||||
label: 'Trip',
|
||||
createdAt: shared.createdAt,
|
||||
revokedAt: null,
|
||||
revokedBy: null,
|
||||
});
|
||||
|
||||
const withMe = await req(
|
||||
'GET',
|
||||
'/kv/shares/with-me?limit=200',
|
||||
env.users.other.token,
|
||||
);
|
||||
const withMeText = await withMe.text();
|
||||
const withMePage = JSON.parse(withMeText) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(
|
||||
withMePage.items.find((row) => row.handle === shared.handle),
|
||||
).toEqual({
|
||||
handle: shared.handle,
|
||||
access: 'write',
|
||||
appUid: null,
|
||||
label: 'Trip',
|
||||
createdAt: shared.createdAt,
|
||||
});
|
||||
expect(withMeText).not.toContain(env.users.user.username);
|
||||
expect(withMeText).not.toContain(ownerUuid);
|
||||
expect(withMeText).not.toContain(String(shared.prefix));
|
||||
});
|
||||
|
||||
it('lets the holder unshare, and shows the owner they did', async () => {
|
||||
const shared = await shareWithOther();
|
||||
const removed = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${shared.handle}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(removed.status).toBe(200);
|
||||
await expect(removed.json()).resolves.toMatchObject({
|
||||
handle: shared.handle,
|
||||
});
|
||||
|
||||
const byMe = (await (
|
||||
await req('GET', '/kv/shares?limit=200', env.users.user.token)
|
||||
).json()) as { items: Array<Record<string, unknown>> };
|
||||
expect(
|
||||
byMe.items.find((row) => row.handle === shared.handle),
|
||||
).toMatchObject({ revokedBy: 'holder' });
|
||||
});
|
||||
|
||||
it('keeps the owner and prefix out of the holder`s grant audit after they unshare', async () => {
|
||||
const shared = await shareWithOther({ access: 'write' });
|
||||
const segment = String(shared.prefix).slice(0, -1);
|
||||
const other = await env.server.stores.user.getByUsername(
|
||||
env.users.other.username,
|
||||
);
|
||||
// A row an earlier build filed under the holder.
|
||||
await env.server.stores.permission.auditUserUserPerm({
|
||||
holder_user_id: other!.id,
|
||||
issuer_user_id: other!.id,
|
||||
permission: `kv-share-write:${ownerUuid}:os-global:${segment}`,
|
||||
action: 'revoke',
|
||||
reason: 'kv share handle unshared',
|
||||
});
|
||||
const removed = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${shared.handle}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(removed.status).toBe(200);
|
||||
|
||||
// The revoke is filed under the owner, who granted it.
|
||||
const auditOf = async (token: string) => {
|
||||
const res = await req('GET', '/share/audit?limit=200', token);
|
||||
expect(res.status).toBe(200);
|
||||
return await res.text();
|
||||
};
|
||||
await vi.waitFor(async () => {
|
||||
const owners = JSON.parse(await auditOf(env.users.user.token)) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(owners.items).toContainEqual(
|
||||
expect.objectContaining({
|
||||
action: 'revoke',
|
||||
issuer: env.users.user.username,
|
||||
holder: env.users.other.username,
|
||||
permission: expect.stringContaining(segment),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const holders = await auditOf(env.users.other.token);
|
||||
expect(holders).not.toContain(ownerUuid);
|
||||
expect(holders).not.toContain(segment);
|
||||
});
|
||||
|
||||
it('lets the owner revoke', async () => {
|
||||
const shared = await shareWithOther();
|
||||
const removed = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${shared.handle}`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(removed.status).toBe(200);
|
||||
});
|
||||
|
||||
it('answers anyone else, and an unknown handle, with the same 404', async () => {
|
||||
const shared = await shareWithOther();
|
||||
const stranger = await createTestUser(env.server, {
|
||||
username: `kvs-${uuidv4().slice(0, 8)}`,
|
||||
password: 'pw-test-1234',
|
||||
});
|
||||
const strangers = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${shared.handle}`,
|
||||
stranger.token,
|
||||
);
|
||||
const unknownHandle = `kvh-${uuidv4()}`;
|
||||
const unknown = await req(
|
||||
'DELETE',
|
||||
`/kv/shares/${unknownHandle}`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(strangers.status).toBe(404);
|
||||
expect(unknown.status).toBe(404);
|
||||
const a = (await strangers.json()) as Record<string, unknown>;
|
||||
const b = (await unknown.json()) as Record<string, unknown>;
|
||||
expect(a.code).toBe(b.code);
|
||||
expect(a.message).toBe(`No such handle: ${shared.handle}`);
|
||||
expect(b.message).toBe(`No such handle: ${unknownHandle}`);
|
||||
});
|
||||
|
||||
it('turns an anonymous caller away', async () => {
|
||||
const res = await req('GET', '/kv/shares');
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/events/kv-handles beside /kv/shares', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
events: { enabled: true, kvHandles: true },
|
||||
kv: { shareHandles: true },
|
||||
unlimitedMetering: true,
|
||||
} as IConfig);
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
it('keeps its own request and response shapes, at watch by default', async () => {
|
||||
const minted = await call(
|
||||
env,
|
||||
'POST',
|
||||
'/events/kv-handles',
|
||||
env.users.user.token,
|
||||
{
|
||||
granteeUsername: env.users.other.username,
|
||||
prefix: 'alias-shape:',
|
||||
},
|
||||
);
|
||||
expect(minted.status).toBe(200);
|
||||
const body = (await minted.json()) as Record<string, unknown>;
|
||||
expect(body).toEqual({ handle: body.handle, prefix: 'alias-shape:' });
|
||||
|
||||
const listed = (await (
|
||||
await call(
|
||||
env,
|
||||
'GET',
|
||||
'/events/kv-handles?limit=200',
|
||||
env.users.user.token,
|
||||
)
|
||||
).json()) as { items: Array<Record<string, unknown>> };
|
||||
const row = listed.items.find((one) => one.handle === body.handle);
|
||||
expect(row).toMatchObject({
|
||||
prefix: 'alias-shape:',
|
||||
appUid: 'os-global',
|
||||
granteeUsername: env.users.other.username,
|
||||
access: 'watch',
|
||||
revokedBy: null,
|
||||
});
|
||||
expect(row).not.toHaveProperty('holder');
|
||||
|
||||
// The alias revoke stays the owner's: the holder is told it is absent.
|
||||
const byHolder = await call(
|
||||
env,
|
||||
'DELETE',
|
||||
`/events/kv-handles/${body.handle}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(byHolder.status).toBe(404);
|
||||
});
|
||||
|
||||
it('hands back a share it finds at the level it has, unless the body names one', async () => {
|
||||
const shared = (await (
|
||||
await call(env, 'POST', '/kv/shares', env.users.user.token, {
|
||||
prefix: 'alias-keep:',
|
||||
recipient: env.users.other.username,
|
||||
access: 'write',
|
||||
})
|
||||
).json()) as Record<string, unknown>;
|
||||
const accessOf = async () => {
|
||||
const listed = (await (
|
||||
await call(
|
||||
env,
|
||||
'GET',
|
||||
'/kv/shares?limit=200',
|
||||
env.users.user.token,
|
||||
)
|
||||
).json()) as { items: Array<Record<string, unknown>> };
|
||||
return listed.items.find((one) => one.handle === shared.handle)
|
||||
?.access;
|
||||
};
|
||||
const mintAlias = (extra: Record<string, unknown> = {}) =>
|
||||
call(env, 'POST', '/events/kv-handles', env.users.user.token, {
|
||||
granteeUsername: env.users.other.username,
|
||||
prefix: 'alias-keep:',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const again = await mintAlias();
|
||||
expect(again.status).toBe(200);
|
||||
await expect(again.json()).resolves.toEqual({
|
||||
handle: shared.handle,
|
||||
prefix: 'alias-keep:',
|
||||
});
|
||||
expect(await accessOf()).toBe('write');
|
||||
|
||||
expect((await mintAlias({ access: 'read' })).status).toBe(200);
|
||||
expect(await accessOf()).toBe('read');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Sharing asks for a confirmed email and nothing else. Booted with an SMS
|
||||
* provider configured, so a phone requirement would bite here if one were ever
|
||||
* added to these routes.
|
||||
*/
|
||||
describe('verification on the mint routes', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
events: { enabled: true, kvHandles: true },
|
||||
kv: { shareHandles: true },
|
||||
unlimitedMetering: true,
|
||||
strict_email_verification_required: true,
|
||||
prelude: { apiKey: 'test-key' },
|
||||
} as IConfig);
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const makeUser = async (
|
||||
patch: Record<string, unknown> = {},
|
||||
): Promise<TestUserCredentials> => {
|
||||
const username = `kvv${uuidv4().slice(0, 8)}`;
|
||||
const creds = await createTestUser(env.server, {
|
||||
username,
|
||||
password: 'pw-test-1234',
|
||||
});
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const row = await env.server.stores.user.getByUsername(username);
|
||||
await env.server.stores.user.update(row!.id, patch);
|
||||
}
|
||||
return creds;
|
||||
};
|
||||
|
||||
const mintBoth = (owner: TestUserCredentials, prefix: string) =>
|
||||
Promise.all([
|
||||
call(env, 'POST', '/kv/shares', owner.token, {
|
||||
prefix: `${prefix}a:`,
|
||||
recipient: env.users.other.username,
|
||||
}),
|
||||
call(env, 'POST', '/events/kv-handles', owner.token, {
|
||||
prefix: `${prefix}b:`,
|
||||
granteeUsername: env.users.other.username,
|
||||
}),
|
||||
]);
|
||||
|
||||
it('refuses an account whose email is not confirmed, on both routes', async () => {
|
||||
const owner = await makeUser();
|
||||
for (const res of await mintBoth(owner, 'unconfirmed')) {
|
||||
expect(res.status).toBe(403);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
code: 'account_is_not_verified',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a confirmed account share with no phone and no card', async () => {
|
||||
const owner = await makeUser({
|
||||
email: `kvv-${uuidv4().slice(0, 8)}@example.invalid`,
|
||||
email_confirmed: 1,
|
||||
});
|
||||
for (const res of await mintBoth(owner, 'confirmed'))
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('never gates listing or unsharing', async () => {
|
||||
const owner = await makeUser();
|
||||
for (const path of ['/kv/shares', '/kv/shares/with-me'])
|
||||
expect((await call(env, 'GET', path, owner.token)).status).toBe(
|
||||
200,
|
||||
);
|
||||
const removed = await call(
|
||||
env,
|
||||
'DELETE',
|
||||
`/kv/shares/kvh-${uuidv4()}`,
|
||||
owner.token,
|
||||
);
|
||||
expect(removed.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,172 +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 type { Actor } from '../../core/actor.js';
|
||||
import { Controller, Delete, Get, Post } from '../../core/http/decorators.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { bodyRecord } from '../../core/http/requestBody.js';
|
||||
import { parseKvShareRecipient } from '../../services/kvShare/kvShares.js';
|
||||
import type { KvShareHandleListOptions } from '../../stores/events/KvShareHandleStore.js';
|
||||
import { KV_HANDLE_LIST_LIMIT_CAP } from '../../stores/events/KvShareHandleStore.js';
|
||||
import { KV_GLOBAL_APP_KEY } from '../../stores/systemKv/SystemKVStore.js';
|
||||
import { normalizeLimit } from '../../util/pagination.js';
|
||||
import { EVENTS_LIST_LIMIT } from '../events/limits.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
/** The app-less namespace is no app, so the wire says `null` for it. */
|
||||
const publicAppUid = (appUid: string): string | null =>
|
||||
appUid === KV_GLOBAL_APP_KEY ? null : appUid;
|
||||
|
||||
const pageOptions = (req: Request): KvShareHandleListOptions => {
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
limit: normalizeLimit(query.limit, { cap: KV_HANDLE_LIST_LIMIT_CAP }),
|
||||
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
|
||||
includeTotal: query.includeTotal === 'true',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Sharing a region of one account's key-value data with another account.
|
||||
*
|
||||
* Gated like the events surface: `subdomain: 'api'` plus `allowAccessToken`,
|
||||
* with app-ness decided from `effectiveApp` in the service, which is where the
|
||||
* owner, holder, delegation and namespace rules live. Only sharing asks for a
|
||||
* confirmed email; listing, revoking and unsharing only narrow or report.
|
||||
*/
|
||||
@Controller('/kv')
|
||||
export class KvShareController extends PuterController {
|
||||
/**
|
||||
* POST /kv/shares — share a region with another account, `read` unless the
|
||||
* body asks otherwise. Sharing the same region with the same account again
|
||||
* changes its level or label in place.
|
||||
*/
|
||||
@Post('/shares', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
})
|
||||
async share(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = bodyRecord(req);
|
||||
const share = await this.services.kvShare.mintKvHandle(
|
||||
actor,
|
||||
{
|
||||
prefix: body.prefix,
|
||||
recipient: parseKvShareRecipient(body.recipient),
|
||||
access: body.access,
|
||||
appUid: body.appUid,
|
||||
label: body.label,
|
||||
},
|
||||
{ defaultAccess: 'read' },
|
||||
);
|
||||
res.json({
|
||||
handle: share.handle,
|
||||
prefix: share.prefix,
|
||||
access: share.access,
|
||||
appUid: publicAppUid(share.appUid),
|
||||
holder: share.holderUsername,
|
||||
label: share.label,
|
||||
createdAt: share.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /kv/shares — what this account has shared out, retired included. */
|
||||
@Get('/shares', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_LIST_LIMIT,
|
||||
})
|
||||
async listSharedByMe(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const page = await this.services.kvShare.listKvHandles(
|
||||
actor,
|
||||
pageOptions(req),
|
||||
);
|
||||
res.json({
|
||||
items: page.items.map((row) => ({
|
||||
handle: row.handle,
|
||||
prefix: row.prefix,
|
||||
access: row.access,
|
||||
appUid: publicAppUid(row.appUid),
|
||||
holder: row.holderUsername,
|
||||
label: row.label,
|
||||
createdAt: row.createdAt,
|
||||
revokedAt: row.revokedAt,
|
||||
revokedBy: row.revokedBy,
|
||||
})),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total !== undefined ? { total: page.total } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /kv/shares/with-me — live shares this account holds. */
|
||||
@Get('/shares/with-me', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_LIST_LIMIT,
|
||||
})
|
||||
async listSharedWithMe(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const page = await this.services.kvShare.listKvSharesWithMe(
|
||||
actor,
|
||||
pageOptions(req),
|
||||
);
|
||||
res.json({
|
||||
items: page.items.map((row) => ({
|
||||
handle: row.handle,
|
||||
access: row.access,
|
||||
appUid: publicAppUid(row.appUid),
|
||||
label: row.label,
|
||||
createdAt: row.createdAt,
|
||||
})),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total !== undefined ? { total: page.total } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /kv/shares/:handle — the owner revokes, the holder gives it up,
|
||||
* and anyone else is told it is not there.
|
||||
*/
|
||||
@Delete('/shares/:handle', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async unshare(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.kvShare.removeKvShare(actor, req.params.handle),
|
||||
);
|
||||
}
|
||||
|
||||
#requireActor(req: Request): Actor {
|
||||
const actor = req.actor;
|
||||
if (!actor?.user)
|
||||
throw new HttpError(401, 'Unauthorized', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
return actor;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@
|
||||
* 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,
|
||||
@@ -234,14 +233,20 @@ 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'] });
|
||||
@@ -412,9 +417,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);
|
||||
}
|
||||
@@ -619,7 +624,10 @@ 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);
|
||||
});
|
||||
@@ -783,12 +791,14 @@ 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;
|
||||
@@ -828,12 +838,14 @@ 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.
|
||||
@@ -968,12 +980,14 @@ 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 });
|
||||
@@ -1061,12 +1075,14 @@ 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 });
|
||||
@@ -1216,7 +1232,13 @@ describe('OIDCController login callback', () => {
|
||||
email,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
});
|
||||
installSeatStub(server as never, [seat.id]);
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
// Provisioning issues a temporary password once the seat exists.
|
||||
await server.stores.user.update(seat.id, { password: 'hashed' });
|
||||
return seat;
|
||||
@@ -1312,12 +1334,14 @@ 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');
|
||||
@@ -1652,12 +1676,14 @@ 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);
|
||||
|
||||
@@ -1729,12 +1755,14 @@ 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 });
|
||||
@@ -1871,12 +1899,14 @@ 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);
|
||||
|
||||
@@ -1908,12 +1938,14 @@ 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;
|
||||
@@ -1960,12 +1992,14 @@ 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;
|
||||
@@ -2004,7 +2038,12 @@ 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');
|
||||
@@ -2018,9 +2057,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,12 +24,6 @@ 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';
|
||||
@@ -49,6 +43,11 @@ 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';
|
||||
|
||||
@@ -285,6 +284,11 @@ 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);
|
||||
@@ -799,9 +803,18 @@ if (window.opener) {
|
||||
|
||||
/** Sign a popup-return proof, bound to this browser and this popup. */
|
||||
#mintPopupReturn(res: Response, payload: Record<string, unknown>): string {
|
||||
return mintOidcPopupReturn(res, payload, {
|
||||
config: this.config,
|
||||
oidc: this.services.oidc,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -859,7 +872,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) {
|
||||
@@ -999,71 +1012,49 @@ 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,
|
||||
});
|
||||
res.redirect(
|
||||
302,
|
||||
completeOidcLoginReturn(res, user, continuation, {
|
||||
config: this.config,
|
||||
oidc: this.services.oidc,
|
||||
}),
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,978 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,565 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 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,6 +1,5 @@
|
||||
// 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';
|
||||
@@ -737,9 +736,9 @@ describe('WebDAVController', () => {
|
||||
otp_enabled: 1,
|
||||
otp_secret: secret,
|
||||
});
|
||||
// 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');
|
||||
const seat = vi
|
||||
.spyOn(server.stores.team, 'getOrgSeat')
|
||||
.mockResolvedValue({ require_2fa: 1 } as never);
|
||||
try {
|
||||
const code = new TOTP({
|
||||
issuer: 'puter.com',
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
* 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';
|
||||
@@ -289,7 +288,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 as Team2faLookup);
|
||||
await assertTeam2fa(actor.user, this.stores.team);
|
||||
|
||||
// The actor was absent when the auth probe ran, so anything that reads
|
||||
// it off the request or the context — shared-path masking, egress
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
makeActor,
|
||||
SYSTEM_ACTOR,
|
||||
userRelatedActor,
|
||||
viaWorkerOf,
|
||||
type Actor,
|
||||
} from './actor';
|
||||
|
||||
@@ -112,28 +111,3 @@ describe('assertResolvedActor', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viaWorkerOf', () => {
|
||||
const user = { uuid: 'u-1', id: 1 };
|
||||
|
||||
it('reads a worker session or an events handler as server-side', () => {
|
||||
expect(
|
||||
viaWorkerOf(
|
||||
makeActor({ user, session: { uid: 's-1', kind: 'worker' } }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(viaWorkerOf(makeActor({ user, handlerDepth: 1 }))).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a browser session, an app or no actor at all as not', () => {
|
||||
expect(
|
||||
viaWorkerOf(
|
||||
makeActor({ user, session: { uid: 's-1', kind: 'web' } }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(viaWorkerOf(makeActor({ user, app: { uid: 'app-1' } }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(viaWorkerOf(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -239,10 +239,3 @@ export const userRelatedActor = (actor: Actor): Actor => {
|
||||
/** How many events handler runs led to a write by this actor. */
|
||||
export const handlerDepthOf = (actor: Actor | undefined | null): number =>
|
||||
actor?.handlerDepth ?? 0;
|
||||
|
||||
/**
|
||||
* Whether a write by this actor was made by server-side code running as the
|
||||
* account: a worker session, or an events handler.
|
||||
*/
|
||||
export const viaWorkerOf = (actor: Actor | undefined | null): boolean =>
|
||||
actor?.session?.kind === 'worker' || handlerDepthOf(actor) > 0;
|
||||
@@ -67,12 +67,6 @@ 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
|
||||
@@ -90,7 +84,6 @@ 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,7 +29,6 @@ import {
|
||||
createNativeAppStatic,
|
||||
createUserSubdomainNotFound,
|
||||
createWwwRedirect,
|
||||
subdomainLabelOf,
|
||||
type ResolvedClaim,
|
||||
type SubdomainClaim,
|
||||
} from './hostRedirects';
|
||||
@@ -128,18 +127,6 @@ 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),
|
||||
@@ -689,32 +676,6 @@ 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 };
|
||||
|
||||
@@ -891,18 +852,6 @@ 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,18 +99,6 @@ 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.
|
||||
@@ -207,8 +195,6 @@ 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();
|
||||
@@ -276,8 +262,7 @@ 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 &&
|
||||
subdomainLabelOf(hostName, domain) === active
|
||||
req.subdomains?.length === 1 && hostName === `${active}.${domain}`
|
||||
? claim
|
||||
: undefined;
|
||||
if (claimed) {
|
||||
@@ -307,7 +292,6 @@ export const createNativeAppStatic = (config: IConfig): RequestHandler => {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
return async (req, res, next) => {
|
||||
if (req.is_custom_domain) return next();
|
||||
const active = (
|
||||
req.subdomains?.[req.subdomains.length - 1] ?? ''
|
||||
).toLowerCase();
|
||||
|
||||
@@ -950,8 +950,7 @@ describe('resolvePrivateIdentity', () => {
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores an app-issued access token even for the target app', async () => {
|
||||
// A scoped token (e.g. a read URL) is not the account, whoever issued it.
|
||||
it('accepts an app-issued access token minted for the target app', async () => {
|
||||
const user = await makeUser();
|
||||
const targetUid = `app-target-${uuidv4()}`;
|
||||
const issuer = makeActor({
|
||||
@@ -971,11 +970,11 @@ describe('resolvePrivateIdentity', () => {
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: targetUid,
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
expect(out.source).toBe('session-cookie');
|
||||
expect(out.userUid).toBe(user.uuid);
|
||||
});
|
||||
|
||||
it('ignores a user-issued scoped access token', async () => {
|
||||
it('still accepts a user-issued access token, which is portable', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
@@ -984,46 +983,7 @@ describe('resolvePrivateIdentity', () => {
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: `app-target-${uuidv4()}`,
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a scoped access token when the host has no app', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
});
|
||||
|
||||
it('accepts a full-access token, which holds the account', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: {
|
||||
uid: `tok-${uuidv4()}`,
|
||||
issuer,
|
||||
fullAccess: true,
|
||||
},
|
||||
session: { uid: 'sess-1' },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '../../../services/auth/AuthService';
|
||||
import type { AppStore } from '../../../stores/app/AppStore.js';
|
||||
import type { IConfig } from '../../../types';
|
||||
import { isAccessTokenActor, isAccountContext, type Actor } from '../../actor';
|
||||
import type { Actor } from '../../actor';
|
||||
|
||||
/**
|
||||
* Support helpers for the private-app access gate — ported from v1's
|
||||
@@ -196,12 +196,10 @@ 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.host ?? opts.req.hostname);
|
||||
const host = normalizeHost(opts.req.hostname);
|
||||
if (!host) return null;
|
||||
|
||||
const hostedSubdomain = subdomainFromHost(host, [
|
||||
@@ -217,7 +215,7 @@ export async function resolveOwnedAppForHostedSite(opts: {
|
||||
const hostCandidates = new Set<string>();
|
||||
hostCandidates.add(host);
|
||||
const headerHost =
|
||||
!opts.host && typeof opts.req.headers?.host === 'string'
|
||||
typeof opts.req.headers?.host === 'string'
|
||||
? opts.req.headers.host.trim().toLowerCase()
|
||||
: '';
|
||||
if (headerHost) hostCandidates.add(headerHost);
|
||||
@@ -460,15 +458,11 @@ export async function resolvePrivateIdentity(opts: {
|
||||
* "No app anywhere in the chain" is the portable case, so read `effectiveApp` —
|
||||
* an app-issued access token has no `app` of its own and would otherwise pass
|
||||
* as a plain session on any private host.
|
||||
*
|
||||
* A scoped access token (a read URL, whoever issued it) is never the account
|
||||
* here: the hosted cookie minted from it would outlive the token's revocation.
|
||||
*/
|
||||
function actorMatchesExpectedApp(
|
||||
actor: Actor,
|
||||
expectedAppUid: string | undefined,
|
||||
): boolean {
|
||||
if (isAccessTokenActor(actor) && !isAccountContext(actor)) return false;
|
||||
if (!expectedAppUid) return true;
|
||||
const app = actor.effectiveApp;
|
||||
if (!app) return true;
|
||||
|
||||
@@ -1030,133 +1030,6 @@ 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();
|
||||
@@ -1381,7 +1254,12 @@ 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();
|
||||
@@ -1495,9 +1373,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,60 +228,29 @@ export const createPuterSiteMiddleware = (
|
||||
if (!host) return next();
|
||||
|
||||
const matched = matchHostingDomain(host);
|
||||
// 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();
|
||||
if (!matched) return next();
|
||||
|
||||
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');
|
||||
// Bare hosting domain (e.g. `puter.site`) → redirect to the main site.
|
||||
if (host === matched) {
|
||||
if (domain) {
|
||||
res.redirect(302, `${req.protocol}://${domain}`);
|
||||
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;
|
||||
}
|
||||
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 - hostingDomain.length - 1,
|
||||
);
|
||||
subdomain = prefix.split('.')[0] || '';
|
||||
// `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] || '';
|
||||
|
||||
if (!subdomain || subdomain === 'www') {
|
||||
if (domain) {
|
||||
res.redirect(302, `${req.protocol}://${domain}`);
|
||||
return;
|
||||
}
|
||||
res.status(404).type('text/plain').send('Subdomain not found');
|
||||
if (!subdomain || subdomain === 'www') {
|
||||
if (domain) {
|
||||
res.redirect(302, `${req.protocol}://${domain}`);
|
||||
return;
|
||||
}
|
||||
res.status(404).type('text/plain').send('Subdomain not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const site = (await layers.stores.subdomain.getBySubdomain(
|
||||
@@ -317,11 +286,6 @@ 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 =
|
||||
@@ -336,14 +300,7 @@ export const createPuterSiteMiddleware = (
|
||||
const isPrivateApp = Boolean(associatedApp?.is_private);
|
||||
const privateApp = isPrivateApp ? associatedApp : null;
|
||||
|
||||
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) {
|
||||
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
|
||||
@@ -491,7 +448,7 @@ export const createPuterSiteMiddleware = (
|
||||
res.redirect(302, cleanPath);
|
||||
return;
|
||||
}
|
||||
} else if (privateHostingDomains.has(matched as string)) {
|
||||
} else if (privateHostingDomains.has(matched)) {
|
||||
// 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)
|
||||
|
||||
@@ -1,81 +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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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, body: string) => void;
|
||||
let onRequest: (res: ServerResponse) => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
upstream = createServer((req, res) => {
|
||||
@@ -44,12 +44,8 @@ describe('chat cancellation over HTTP', () => {
|
||||
res.end(JSON.stringify({ models: [{ name: 'cancel-test' }] }));
|
||||
return;
|
||||
}
|
||||
let body = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk: string) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.once('end', () => onRequest(res, body));
|
||||
req.resume();
|
||||
req.once('end', () => onRequest(res));
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
upstream.listen(0, '127.0.0.1', resolve),
|
||||
@@ -147,137 +143,24 @@ 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 })),
|
||||
),
|
||||
)(
|
||||
'bills an upstream the caller left while waiting for headers: $route (stream=$stream)',
|
||||
'cancels upstream while waiting for headers: $route (stream=$stream)',
|
||||
async ({ route, stream }) => {
|
||||
let upstreamRes!: ServerResponse;
|
||||
let upstreamBody = '';
|
||||
let upstreamClosedEarly = false;
|
||||
let upstreamClosed = false;
|
||||
let received!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
received = resolve;
|
||||
});
|
||||
onRequest = (res, body) => {
|
||||
upstreamRes = res;
|
||||
upstreamBody = body;
|
||||
onRequest = (res) => {
|
||||
res.once('close', () => {
|
||||
if (!res.writableFinished) upstreamClosedEarly = true;
|
||||
upstreamClosed = 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,
|
||||
@@ -293,50 +176,36 @@ 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);
|
||||
|
||||
finishAnswer(upstreamRes, upstreamBody);
|
||||
abort.abort();
|
||||
expect(await request).toMatchObject({ name: 'AbortError' });
|
||||
await expect.poll(() => upstreamClosed).toBe(true);
|
||||
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)(
|
||||
'bills an upstream the caller left after partial output: %s',
|
||||
'cancels an idle upstream after partial output: %s',
|
||||
async (route) => {
|
||||
let upstreamRes!: ServerResponse;
|
||||
let upstreamBody = '';
|
||||
let upstreamClosedEarly = false;
|
||||
onRequest = (res, body) => {
|
||||
upstreamRes = res;
|
||||
upstreamBody = body;
|
||||
let upstreamClosed = false;
|
||||
onRequest = (res) => {
|
||||
res.once('close', () => {
|
||||
if (!res.writableFinished) upstreamClosedEarly = true;
|
||||
upstreamClosed = true;
|
||||
});
|
||||
writeText(res);
|
||||
};
|
||||
const metered = vi.spyOn(
|
||||
env.server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
);
|
||||
const abort = new AbortController();
|
||||
try {
|
||||
const response = await call(route, abort.signal);
|
||||
@@ -350,20 +219,14 @@ describe('chat cancellation over HTTP', () => {
|
||||
text += new TextDecoder().decode(chunk.value);
|
||||
}
|
||||
abort.abort();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(upstreamClosedEarly).toBe(false);
|
||||
|
||||
finishAnswer(upstreamRes, upstreamBody);
|
||||
await expect.poll(() => upstreamClosed).toBe(true);
|
||||
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,40 +633,6 @@ 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(
|
||||
() =>
|
||||
@@ -843,26 +809,6 @@ 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,8 +47,7 @@ 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 { SDK_TIMEOUT_MS } from './utils/sdkClient.js';
|
||||
import type { AIChatStream } from './utils/Streaming.js';
|
||||
import { type AIChatStream, ChatStreamAbortedError } from './utils/Streaming.js';
|
||||
|
||||
let server: PuterServer;
|
||||
|
||||
@@ -906,7 +905,7 @@ describe('ChatCompletionDriver when the caller hangs up', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps generating after the hang-up and bills the reported usage', async () => {
|
||||
it('stops the generation and gives the hold back', async () => {
|
||||
const actor = freeUser() as never;
|
||||
const metering = server.services.metering;
|
||||
const abort = new AbortController();
|
||||
@@ -915,7 +914,6 @@ describe('ChatCompletionDriver when the caller hangs up', () => {
|
||||
resume = r;
|
||||
});
|
||||
let providerError: unknown;
|
||||
let finished = false;
|
||||
|
||||
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce(
|
||||
streamOf(async ({ chatStream }) => {
|
||||
@@ -923,13 +921,8 @@ describe('ChatCompletionDriver when the caller hangs up', () => {
|
||||
block.addText('first');
|
||||
await paused;
|
||||
try {
|
||||
// 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;
|
||||
// An upstream still generating: keeps writing until told.
|
||||
for (let i = 0; i < 1000; i++) block.addText('more');
|
||||
} catch (e) {
|
||||
providerError = e;
|
||||
throw e;
|
||||
@@ -937,7 +930,6 @@ 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);
|
||||
@@ -958,120 +950,15 @@ describe('ChatCompletionDriver when the caller hangs up', () => {
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
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);
|
||||
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.
|
||||
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,7 +116,6 @@ 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 {
|
||||
@@ -324,17 +323,8 @@ 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,
|
||||
);
|
||||
@@ -681,10 +671,6 @@ 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;
|
||||
|
||||
@@ -885,8 +871,6 @@ 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,
|
||||
@@ -1049,20 +1033,10 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
const init = res.init_chat_stream;
|
||||
const cleanup = res.finally_fn;
|
||||
|
||||
// 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);
|
||||
// Providers also receive this signal to cancel while waiting for output.
|
||||
const abortStream = () => chatStream.abort();
|
||||
if (abortSignal?.aborted) abortStream();
|
||||
else abortSignal?.addEventListener('abort', abortStream);
|
||||
|
||||
// Intercept `chatStream.end(usage)` to fire complete + cost events
|
||||
// (mirrors the non-streaming branch). Clone usage so providers that
|
||||
@@ -1086,9 +1060,9 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
enrichedUsage.cost = charged();
|
||||
}
|
||||
}
|
||||
// A completion nobody received isn't announced, but it still
|
||||
// gets its cost row.
|
||||
if (!chatStream.detached) {
|
||||
// A cancelled stream isn't a completion, but whatever it
|
||||
// metered still gets its cost row.
|
||||
if (!chatStream.aborted) {
|
||||
this.clients.event.emit(
|
||||
'ai.prompt.complete',
|
||||
{
|
||||
@@ -1155,13 +1129,13 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
(async () => {
|
||||
try {
|
||||
await init({ chatStream });
|
||||
if (!passthrough.writableEnded) {
|
||||
if (!chatStream.aborted && !passthrough.writableEnded) {
|
||||
throw new Error('Stream ended before completion');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!chatStream.detached) {
|
||||
if (!chatStream.aborted) {
|
||||
const attempt = toAttempt(model.id, model.provider!, e);
|
||||
chatStream.write(
|
||||
passthrough.write(
|
||||
`${JSON.stringify({
|
||||
type: 'error',
|
||||
message: sanitizeUpstreamMessage(
|
||||
@@ -1177,8 +1151,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
passthrough.end();
|
||||
} finally {
|
||||
clearInterval(renewHold);
|
||||
clearTimeout(detachedLimit);
|
||||
abortSignal?.removeEventListener('abort', detachStream);
|
||||
abortSignal?.removeEventListener('abort', abortStream);
|
||||
// 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 when the upstream signal aborts', async () => {
|
||||
it('cancels the simulated delay before writing output', async () => {
|
||||
await withTestActor(async () => {
|
||||
const abort = new AbortController();
|
||||
Context.set('upstreamSignal', abort.signal);
|
||||
Context.set('abortSignal', 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('finishes without writing after the caller detaches', async () => {
|
||||
it('does not write after the chat stream was aborted independently', async () => {
|
||||
const result = await new FakeChatProvider().complete({
|
||||
model: 'fake',
|
||||
stream: true,
|
||||
@@ -67,9 +67,8 @@ describe('fake chat streaming lifecycle', () => {
|
||||
});
|
||||
const stream = new PassThrough();
|
||||
const chatStream = new AIChatStream({ stream });
|
||||
chatStream.detach();
|
||||
chatStream.abort();
|
||||
await result.init_chat_stream!({ chatStream });
|
||||
expect(stream.readableLength).toBe(0);
|
||||
expect(stream.writableEnded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -101,11 +101,12 @@ export class FakeChatProvider implements IChatProvider {
|
||||
chatStream: AIChatStream;
|
||||
}) => {
|
||||
await delay(500, undefined, {
|
||||
signal: Context.get('upstreamSignal'),
|
||||
signal: Context.get('abortSignal'),
|
||||
});
|
||||
const response = await resp;
|
||||
if (chatStream.aborted) return;
|
||||
await chatStream.waitForDrain();
|
||||
chatStream.write(
|
||||
chatStream.stream.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('upstreamSignal');
|
||||
const signal = Context.get('abortSignal');
|
||||
|
||||
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('upstreamSignal') },
|
||||
{ signal: Context.get('abortSignal') },
|
||||
);
|
||||
|
||||
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('upstreamSignal') },
|
||||
{ signal: Context.get('abortSignal') },
|
||||
);
|
||||
return OpenAiUtil.handle_completion_output_responses_api({
|
||||
usage_calculator: ({
|
||||
|
||||
@@ -320,7 +320,7 @@ describe('ClaudeProvider.complete request shape', () => {
|
||||
};
|
||||
|
||||
it.each([false, true])(
|
||||
'passes the upstream signal to the SDK with stream=%s',
|
||||
'passes cancellation 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('upstreamSignal', abort.signal);
|
||||
Context.set('abortSignal', 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('upstreamSignal'),
|
||||
signal: Context.get('abortSignal'),
|
||||
})
|
||||
: this.anthropic.messages.stream(sdkParams as never, {
|
||||
signal: Context.get('upstreamSignal'),
|
||||
signal: Context.get('abortSignal'),
|
||||
});
|
||||
// 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('upstreamSignal'),
|
||||
signal: Context.get('abortSignal'),
|
||||
})
|
||||
: this.anthropic.messages.create(sdkParams as never, {
|
||||
signal: Context.get('upstreamSignal'),
|
||||
signal: Context.get('abortSignal'),
|
||||
}));
|
||||
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 upstream signal already aborted', async () => {
|
||||
it('does not call Google when the request was already cancelled', async () => {
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
const error = await withTestActor(() => {
|
||||
Context.set('upstreamSignal', abort.signal);
|
||||
Context.set('abortSignal', 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 upstream signal to the SDK', async () => {
|
||||
it('passes the request cancellation signal to the SDK', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const abort = new AbortController();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
await withTestActor(() => {
|
||||
Context.set('upstreamSignal', abort.signal);
|
||||
Context.set('abortSignal', 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 upstream signal to the SDK', async () => {
|
||||
it('passes the request cancellation signal to the SDK', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const abort = new AbortController();
|
||||
completeMock.mockResolvedValueOnce(baseCompletion);
|
||||
await withTestActor(() => {
|
||||
Context.set('upstreamSignal', abort.signal);
|
||||
Context.set('abortSignal', abort.signal);
|
||||
return provider.complete({
|
||||
model: 'mistral-small-latest',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
});
|
||||
});
|
||||
// Bounded by the SDK timeout as well, and still aborted with the
|
||||
// upstream signal.
|
||||
// Bounded by the SDK timeout as well, and still cancelled with the
|
||||
// request.
|
||||
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('upstreamSignal')
|
||||
: withSdkTimeout(Context.get('upstreamSignal')),
|
||||
? Context.get('abortSignal')
|
||||
: withSdkTimeout(Context.get('abortSignal')),
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -927,7 +927,7 @@ describe('OpenAiChatProvider.complete error mapping', () => {
|
||||
|
||||
describe('provider request cancellation', () => {
|
||||
it.each([false, true])(
|
||||
'passes the upstream signal to the SDK with stream=%s',
|
||||
'passes cancellation 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('upstreamSignal', abort.signal);
|
||||
Context.set('abortSignal', 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('upstreamSignal') },
|
||||
{ signal: Context.get('abortSignal') },
|
||||
);
|
||||
|
||||
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('upstreamSignal') },
|
||||
{ signal: Context.get('abortSignal') },
|
||||
);
|
||||
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('upstreamSignal') },
|
||||
{ signal: Context.get('abortSignal') },
|
||||
);
|
||||
} catch (e) {
|
||||
if (!Context.get('upstreamSignal')?.aborted) {
|
||||
if (!Context.get('abortSignal')?.aborted) {
|
||||
console.log('XAI AI process_input_messages error: ', e);
|
||||
}
|
||||
throw e;
|
||||
|
||||
@@ -651,9 +651,8 @@ 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');
|
||||
}
|
||||
|
||||
@@ -825,11 +824,8 @@ export const create_chat_stream_handler_responses_api =
|
||||
}
|
||||
}
|
||||
|
||||
if (!completed) {
|
||||
// Some SDKs end an aborted stream as if it were complete.
|
||||
Context.get('upstreamSignal')?.throwIfAborted();
|
||||
throw new Error('Stream ended before completion');
|
||||
}
|
||||
Context.get('abortSignal')?.throwIfAborted();
|
||||
if (!completed) 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('upstreamSignal', abort.signal);
|
||||
Context.set('abortSignal', abort.signal);
|
||||
return handler({
|
||||
completion,
|
||||
usage_calculator: usageCalculator,
|
||||
@@ -1932,58 +1932,6 @@ 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 `detach`. */
|
||||
detached = false;
|
||||
#detachController = new AbortController();
|
||||
/** Set once the caller has gone away; see `abort`. */
|
||||
aborted = false;
|
||||
abortController = new AbortController();
|
||||
|
||||
/**
|
||||
* Stop/usage/context-management state for the end-of-stream `usage` line,
|
||||
@@ -199,31 +199,28 @@ export class AIChatStream {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop writing output once the caller has gone away. The provider keeps
|
||||
* reading its upstream, so the usage the upstream reports still gets
|
||||
* metered.
|
||||
* Stop accepting output after cancellation; providers cancel their requests
|
||||
* too.
|
||||
*/
|
||||
detach() {
|
||||
this.detached = true;
|
||||
this.#detachController.abort();
|
||||
}
|
||||
|
||||
/** False once nothing will read the output. */
|
||||
#writable() {
|
||||
return !this.detached && !this.stream.destroyed;
|
||||
abort() {
|
||||
this.aborted = true;
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
async waitForDrain() {
|
||||
if (!this.#writable() || !this.stream.writableNeedDrain) return;
|
||||
this.#assertOpen();
|
||||
if (!this.stream.writableNeedDrain) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const signal = this.#detachController.signal;
|
||||
const cleanup = () => {
|
||||
this.stream.removeListener('drain', onDone);
|
||||
this.stream.removeListener('close', onDone);
|
||||
this.stream.removeListener('drain', onDrain);
|
||||
this.stream.removeListener('close', onAbort);
|
||||
this.stream.removeListener('error', onError);
|
||||
signal.removeEventListener('abort', onDone);
|
||||
this.abortController.signal.removeEventListener(
|
||||
'abort',
|
||||
onAbort,
|
||||
);
|
||||
};
|
||||
const onDone = () => {
|
||||
const onDrain = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
@@ -231,16 +228,28 @@ export class AIChatStream {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
this.stream.once('drain', onDone);
|
||||
this.stream.once('close', onDone);
|
||||
const onAbort = () => onError(new ChatStreamAbortedError());
|
||||
this.stream.once('drain', onDrain);
|
||||
this.stream.once('close', onAbort);
|
||||
this.stream.once('error', onError);
|
||||
signal.addEventListener('abort', onDone, { once: true });
|
||||
if (!this.#writable() || !this.stream.writableNeedDrain) onDone();
|
||||
this.abortController.signal.addEventListener('abort', onAbort, {
|
||||
once: true,
|
||||
});
|
||||
if (this.stream.destroyed || this.aborted) onAbort();
|
||||
else if (!this.stream.writableNeedDrain) onDrain();
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -270,8 +279,9 @@ export class AIChatStream {
|
||||
* @param {{ alreadyCounted?: boolean }} [opts]
|
||||
*/
|
||||
writeChunk(chunk, { alreadyCounted = false } = {}) {
|
||||
this.#assertOpen();
|
||||
if (!alreadyCounted) this.outputChars += payloadChars(chunk);
|
||||
if (this.#writable()) this.stream.write(`${JSON.stringify(chunk)}\n`);
|
||||
this.stream.write(`${JSON.stringify(chunk)}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -374,7 +384,8 @@ export class AIChatStream {
|
||||
*/
|
||||
end(usage, extra) {
|
||||
this.reportUsage(usage);
|
||||
if (this.#writable()) {
|
||||
// Usage still counts after an abort; only the line has no reader.
|
||||
if (!this.aborted) {
|
||||
const stop = this.stop;
|
||||
const finish_reason = stop?.reason
|
||||
? toFinishReason(stop.reason)
|
||||
@@ -425,7 +436,16 @@ export class AIChatStream {
|
||||
return new AIChatMessageStream(this);
|
||||
}
|
||||
write(...args) {
|
||||
if (this.#writable()) this.stream.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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import Streaming, {
|
||||
AIChatStream,
|
||||
AIChatTextStream,
|
||||
AIChatToolUseStream,
|
||||
ChatStreamAbortedError,
|
||||
} from './Streaming.js';
|
||||
|
||||
// AIChatStream + friends emit newline-delimited JSON to an underlying
|
||||
@@ -188,27 +189,26 @@ describe('AIChatStream stop + usage-detail reporting', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- Detach --
|
||||
// ── Abort ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('AIChatStream.detach', () => {
|
||||
it('stops writing but keeps counting output', () => {
|
||||
describe('AIChatStream.abort', () => {
|
||||
it('throws into the next text, reasoning or tool-call write', () => {
|
||||
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.detach();
|
||||
text.addText('abc');
|
||||
text.addReasoning('de');
|
||||
tool.addPartialJSON('{}');
|
||||
h.chatStream.write('raw');
|
||||
expect(h.chatStream.outputChars).toBe(7);
|
||||
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);
|
||||
expect(h.rawChunks()).toEqual([]);
|
||||
});
|
||||
|
||||
it('still records usage and ends the stream, writing nothing', () => {
|
||||
const h = makeHarness();
|
||||
h.chatStream.detach();
|
||||
h.chatStream.abort();
|
||||
h.chatStream.end({ output_tokens: 3 });
|
||||
expect(h.chatStream.reportedUsage).toEqual({ output_tokens: 3 });
|
||||
expect(h.rawChunks()).toEqual([]);
|
||||
@@ -448,25 +448,16 @@ describe('AIChatStream backpressure', () => {
|
||||
sink.destroy();
|
||||
});
|
||||
|
||||
it.each(['detach', 'close'])('releases a drain wait on %s', async (event) => {
|
||||
it.each(['abort', 'close', 'error'])('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();
|
||||
if (event === 'detach') chatStream.detach();
|
||||
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'));
|
||||
else sink.destroy();
|
||||
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');
|
||||
await rejected;
|
||||
expect(sink.listenerCount('drain')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,6 @@ 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';
|
||||
@@ -1646,34 +1645,3 @@ 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,10 +27,6 @@ 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,
|
||||
@@ -297,7 +293,11 @@ export class OCRDriver extends PuterDriver {
|
||||
|
||||
// Textract state — one client per region.
|
||||
#textractClients: Record<string, TextractClient> = {};
|
||||
#awsConfig: { credentials: AwsCredentials; region: string } | null = null;
|
||||
#awsConfig: {
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
region?: string;
|
||||
} | null = null;
|
||||
/** DetectDocumentText rates per region, from AWS's public price list. */
|
||||
#textractRates = textractRates(OCR_COSTS[TEXTRACT_PAGE_USAGE_TYPE]);
|
||||
|
||||
@@ -314,14 +314,16 @@ export class OCRDriver extends PuterDriver {
|
||||
Record<string, unknown> | undefined;
|
||||
const textractAws = (textract?.aws ?? textract) as
|
||||
Record<string, unknown> | undefined;
|
||||
const textractCredentials = awsCredentialsFrom(textractAws);
|
||||
const textractAccessKey = textractAws?.access_key as string | undefined;
|
||||
const textractSecretKey = textractAws?.secret_key as string | undefined;
|
||||
const textractRegion =
|
||||
(textractAws?.region as string | undefined) ??
|
||||
(textract?.region as string | undefined) ??
|
||||
DEFAULT_TEXTRACT_REGION;
|
||||
if (textractCredentials) {
|
||||
if (textractAccessKey && textractSecretKey) {
|
||||
this.#awsConfig = {
|
||||
credentials: textractCredentials,
|
||||
accessKeyId: textractAccessKey,
|
||||
secretAccessKey: textractSecretKey,
|
||||
region: textractRegion,
|
||||
};
|
||||
// Warm the configured region so the cost report has its rate.
|
||||
@@ -458,7 +460,10 @@ export class OCRDriver extends PuterDriver {
|
||||
const cached = this.#textractClients[region];
|
||||
if (cached) return cached;
|
||||
const client = new TextractClient({
|
||||
credentials: this.#awsConfig!.credentials,
|
||||
credentials: {
|
||||
accessKeyId: this.#awsConfig!.accessKeyId!,
|
||||
secretAccessKey: this.#awsConfig!.secretAccessKey!,
|
||||
},
|
||||
region,
|
||||
});
|
||||
this.#textractClients[region] = client;
|
||||
|
||||
@@ -48,11 +48,7 @@ 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,
|
||||
withAiCostFactorHook,
|
||||
} from '../integrationTestUtil.js';
|
||||
import { callerError, readStreamResult } from '../integrationTestUtil.js';
|
||||
import { VOICE_CHANGER_COSTS } from './costs.js';
|
||||
|
||||
const { webFetchMock } = vi.hoisted(() => ({ webFetchMock: vi.fn() }));
|
||||
@@ -356,35 +352,6 @@ 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,7 +49,6 @@ 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';
|
||||
|
||||
@@ -683,34 +682,6 @@ 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,10 +44,6 @@ 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';
|
||||
|
||||
@@ -692,40 +688,3 @@ 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,7 +19,6 @@
|
||||
|
||||
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 {
|
||||
@@ -242,13 +241,15 @@ export class TTSDriver extends PuterDriver {
|
||||
const polly = providers['aws-polly'];
|
||||
const pollyAws = (polly?.aws ?? polly) as
|
||||
Record<string, unknown> | undefined;
|
||||
const pollyCredentials = awsCredentialsFrom(pollyAws);
|
||||
if (pollyCredentials) {
|
||||
const pollyAccessKey = pollyAws?.access_key as string | undefined;
|
||||
const pollySecretKey = pollyAws?.secret_key as string | undefined;
|
||||
if (pollyAccessKey && pollySecretKey) {
|
||||
register(
|
||||
'aws-polly',
|
||||
() =>
|
||||
new AWSPollyTTSProvider(m, {
|
||||
credentials: pollyCredentials,
|
||||
access_key: pollyAccessKey,
|
||||
secret_key: pollySecretKey,
|
||||
region: (pollyAws?.region ?? polly?.region) as
|
||||
string | undefined,
|
||||
}),
|
||||
|
||||
@@ -101,7 +101,8 @@ const aiMetering = () =>
|
||||
|
||||
const makeProvider = (region?: string) =>
|
||||
new AWSPollyTTSProvider(aiMetering(), {
|
||||
credentials: { accessKeyId: 'AKIA-TEST', secretAccessKey: 'secret' },
|
||||
access_key: 'AKIA-TEST',
|
||||
secret_key: 'secret',
|
||||
region,
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ 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';
|
||||
@@ -84,7 +83,8 @@ export class AWSPollyTTSProvider implements ITTSProvider {
|
||||
constructor(
|
||||
private readonly meteringService: AiMeteringService,
|
||||
private readonly config: {
|
||||
credentials: AwsCredentials;
|
||||
access_key: string;
|
||||
secret_key: string;
|
||||
region?: string;
|
||||
},
|
||||
) {
|
||||
@@ -115,7 +115,10 @@ export class AWSPollyTTSProvider implements ITTSProvider {
|
||||
}
|
||||
|
||||
this.clients[resolvedRegion] = new PollyClient({
|
||||
credentials: cfg.credentials,
|
||||
credentials: {
|
||||
accessKeyId: cfg.access_key,
|
||||
secretAccessKey: cfg.secret_key,
|
||||
},
|
||||
region: resolvedRegion,
|
||||
});
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ 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';
|
||||
@@ -103,11 +102,6 @@ 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;
|
||||
@@ -512,54 +506,6 @@ 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({
|
||||
@@ -1001,49 +947,6 @@ 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();
|
||||
|
||||
@@ -1077,77 +980,6 @@ 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();
|
||||
|
||||
@@ -1193,31 +1025,3 @@ 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,7 +41,6 @@ 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,
|
||||
@@ -51,9 +50,7 @@ import type {
|
||||
|
||||
const DEFAULT_PROVIDER = 'together-video-generation';
|
||||
|
||||
/** 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 isResolutionTier = (value: string): boolean => /^\d{3,4}p$/i.test(value);
|
||||
|
||||
const parsePixelSize = (
|
||||
value: string | undefined,
|
||||
@@ -66,15 +63,14 @@ const parsePixelSize = (
|
||||
};
|
||||
|
||||
/** Resolution tier of the shorter side, e.g. 1920x1080 and 1080x1920 → 1080p. */
|
||||
const tierForPixels = (
|
||||
{ width, height }: { width: number; height: number },
|
||||
dimensions: readonly string[],
|
||||
) => {
|
||||
const tierForPixels = ({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
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';
|
||||
@@ -85,7 +81,7 @@ const tierForPixels = (
|
||||
* Driver implementing the `puter-video-generation` interface.
|
||||
*
|
||||
* Manages multiple upstream providers (Together, BytePlus/Seedance, Gemini,
|
||||
* Kling, MiniMax) and handles model resolution, provider routing, and parameter
|
||||
* Kling) and handles model resolution, provider routing, and parameter
|
||||
* normalisation. Each provider is a plain `IVideoProvider` -- the driver
|
||||
* instantiates them from config on boot.
|
||||
*
|
||||
@@ -102,7 +98,6 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
'byteplus-video-generation',
|
||||
'gemini-video-generation',
|
||||
'kling-video-generation',
|
||||
'minimax-video-generation',
|
||||
];
|
||||
readonly isDefault = true;
|
||||
|
||||
@@ -298,7 +293,7 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
const catalogIsTiers = model.dimensions.every(isResolutionTier);
|
||||
const wanted =
|
||||
catalogIsTiers && requestedPixels
|
||||
? tierForPixels(requestedPixels, model.dimensions)
|
||||
? tierForPixels(requestedPixels)
|
||||
: requestedResolution;
|
||||
|
||||
// Case-insensitive so '4K' matches a catalog entry spelled '4k';
|
||||
@@ -473,26 +468,6 @@ 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
@@ -1,66 +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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,876 +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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -1,690 +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 { 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;
|
||||
}
|
||||
}
|
||||
@@ -1,234 +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 { 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,8 +19,6 @@
|
||||
|
||||
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,
|
||||
@@ -721,10 +719,9 @@ 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). 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.
|
||||
* `<gui origin>/builtin/…` (see BUILTIN_APPS_HOST above). Host comparison
|
||||
* (rather than full origin) deliberately also catches scheme/port variants
|
||||
* of these hosts.
|
||||
*/
|
||||
#assertIndexUrlHostAllowed(indexUrl) {
|
||||
let hostname;
|
||||
@@ -756,13 +753,7 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
const label = config.domain
|
||||
? subdomainLabelOf(hostname, config.domain)
|
||||
: undefined;
|
||||
if (
|
||||
reserved.has(hostname) ||
|
||||
(label !== undefined && extensionStore.claimedSubdomains.has(label))
|
||||
) {
|
||||
if (reserved.has(hostname)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`index_url` cannot point at a Puter system host',
|
||||
|
||||
@@ -21,7 +21,6 @@ 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';
|
||||
@@ -196,105 +195,6 @@ 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,7 +30,6 @@
|
||||
* 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';
|
||||
@@ -61,23 +60,6 @@ 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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -14,10 +14,7 @@ import {
|
||||
APP_DATA_KV_METHOD_OPS,
|
||||
appDataPermission,
|
||||
} from '../../services/permission/appDataScopes.ts';
|
||||
import { kvNamespace } from '../../stores/systemKv/SystemKVStore.ts';
|
||||
import { createTestUser, setupTestServer } from '../../testUtil.ts';
|
||||
import type { IConfig } from '../../types.ts';
|
||||
import { sealCursor, unsealCursor } from '../../util/pagination.ts';
|
||||
import { KV_COSTS } from './costs.ts';
|
||||
import type { KVStoreDriver } from './KVStoreDriver.ts';
|
||||
import {
|
||||
@@ -1555,301 +1552,6 @@ describe('KVStoreDriver', () => {
|
||||
expect(own).toContain('token');
|
||||
});
|
||||
|
||||
// -- Cross-app cursors ----------------------------------------------
|
||||
|
||||
const secret = () =>
|
||||
(target as unknown as { config: IConfig }).config.jwt_secret_v2;
|
||||
|
||||
/** A cursor as the server reads it, its key decoded, or null. */
|
||||
const opened = (cursor: string) => {
|
||||
const payload = unsealCursor(cursor, secret());
|
||||
return payload
|
||||
? {
|
||||
...payload,
|
||||
k: Buffer.from(String(payload.k), 'base64url').toString(
|
||||
'utf8',
|
||||
),
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
/** Everything a cursor shows to the server and to a base64 decoder. */
|
||||
const revealed = (cursor: string) =>
|
||||
[
|
||||
JSON.stringify(opened(cursor)),
|
||||
Buffer.from(cursor, 'base64').toString('utf8'),
|
||||
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||
].join('\n');
|
||||
|
||||
const encode = (payload: unknown) =>
|
||||
Buffer.from(JSON.stringify(payload)).toString('base64');
|
||||
|
||||
type Page = { items: string[]; cursor?: string; total?: number };
|
||||
|
||||
const writeAs = (
|
||||
actorForCall: Actor,
|
||||
key: string,
|
||||
disableSharing = false,
|
||||
) =>
|
||||
inCtx(
|
||||
() =>
|
||||
target.set({
|
||||
key,
|
||||
value: key,
|
||||
...(disableSharing
|
||||
? { optConfig: { disableSharing: true } }
|
||||
: {}),
|
||||
}),
|
||||
actorForCall,
|
||||
);
|
||||
|
||||
it('resumes a cross-app page after a private entry it skipped, padding every cursor to one length', async () => {
|
||||
const { owner, calendar, contacts, calendarActor, contactsActor } =
|
||||
await setup();
|
||||
await writeAs(contactsActor, 'a-hidden-oauth-token-name', true);
|
||||
await writeAs(contactsActor, 'b');
|
||||
await writeAs(contactsActor, 'c-a-much-longer-public-key-name');
|
||||
await grant(owner, calendar.uid, appDataPermission(contacts.uid));
|
||||
const page = (cursor?: string) =>
|
||||
crossApp(calendarActor, contacts.uid, (optConfig) =>
|
||||
target.list({
|
||||
as: 'keys',
|
||||
limit: 1,
|
||||
...(cursor ? { cursor } : {}),
|
||||
optConfig,
|
||||
}),
|
||||
) as Promise<Page>;
|
||||
|
||||
// The scan's first row is the private one.
|
||||
const first = await page();
|
||||
expect(first.items).toEqual(['b']);
|
||||
expect(revealed(first.cursor!)).not.toContain('hidden-oauth');
|
||||
expect(opened(first.cursor!)?.k).toBe('b');
|
||||
|
||||
const second = await page(first.cursor);
|
||||
expect(second.items).toEqual(['c-a-much-longer-public-key-name']);
|
||||
expect(second.cursor!.length).toBe(first.cursor!.length);
|
||||
});
|
||||
|
||||
// Over a thousand writes and reads: slow when the whole suite runs.
|
||||
it('pads a cursor left on a long run of private entries to the same length', async () => {
|
||||
const { owner, calendar, contacts, calendarActor, contactsActor } =
|
||||
await setup();
|
||||
// More than one call reads past, so the page ends on a hidden row.
|
||||
const hidden = Array.from(
|
||||
{ length: 1100 },
|
||||
(_, i) => `h-hidden-${String(i).padStart(4, '0')}`,
|
||||
);
|
||||
for (let at = 0; at < hidden.length; at += 1000)
|
||||
await inCtx(
|
||||
() =>
|
||||
target.batchPut({
|
||||
items: hidden
|
||||
.slice(at, at + 1000)
|
||||
.map((key) => ({ key, value: 1 })),
|
||||
optConfig: { disableSharing: true },
|
||||
}),
|
||||
contactsActor,
|
||||
);
|
||||
await writeAs(contactsActor, 'z');
|
||||
await grant(owner, calendar.uid, appDataPermission(contacts.uid));
|
||||
|
||||
const keys: string[] = [];
|
||||
const cursors: string[] = [];
|
||||
let emptyPages = 0;
|
||||
let cursor: string | undefined;
|
||||
for (let pages = 0; pages < 10; pages++) {
|
||||
const page = (await crossApp(
|
||||
calendarActor,
|
||||
contacts.uid,
|
||||
(optConfig) =>
|
||||
target.list({
|
||||
as: 'keys',
|
||||
limit: 1,
|
||||
...(cursor ? { cursor } : {}),
|
||||
optConfig,
|
||||
}),
|
||||
)) as Page;
|
||||
keys.push(...page.items);
|
||||
if (page.items.length === 0) emptyPages++;
|
||||
cursor = page.cursor;
|
||||
if (!cursor) break;
|
||||
cursors.push(cursor);
|
||||
expect(
|
||||
Buffer.from(cursor, 'base64').toString('utf8'),
|
||||
).not.toContain('h-hidden');
|
||||
}
|
||||
expect(keys).toEqual(['entry', 'z']);
|
||||
expect(emptyPages).toBeGreaterThan(0);
|
||||
expect(new Set(cursors.map((c) => c.length)).size).toBe(1);
|
||||
}, 30_000);
|
||||
|
||||
describe('paging a namespace with private entries in it', () => {
|
||||
// Lengths differ, so an unpadded cursor would show which it is.
|
||||
const PUBLIC = [
|
||||
'entry',
|
||||
'k1',
|
||||
'k3-longer',
|
||||
'k4',
|
||||
'k6-longest-of-all',
|
||||
];
|
||||
const PRIVATE = ['k2', 'k5', 'k7', 'k8'];
|
||||
|
||||
const seeded = async () => {
|
||||
const fixture = await setup();
|
||||
for (const key of PUBLIC.slice(1))
|
||||
await writeAs(fixture.contactsActor, key);
|
||||
for (const key of PRIVATE)
|
||||
await writeAs(fixture.contactsActor, key, true);
|
||||
await grant(
|
||||
fixture.owner,
|
||||
fixture.calendar.uid,
|
||||
appDataPermission(fixture.contacts.uid),
|
||||
);
|
||||
return fixture;
|
||||
};
|
||||
|
||||
it.each(
|
||||
[false, true].flatMap((reverse) =>
|
||||
[false, true].map((includeTotal) => ({
|
||||
reverse,
|
||||
includeTotal,
|
||||
})),
|
||||
),
|
||||
)(
|
||||
'returns every public entry once, reverse $reverse, includeTotal $includeTotal',
|
||||
async ({ reverse, includeTotal }) => {
|
||||
const { contacts, calendarActor } = await seeded();
|
||||
const keys: string[] = [];
|
||||
const lengths = new Set<number>();
|
||||
let cursor: string | undefined;
|
||||
let pages = 0;
|
||||
do {
|
||||
const page = (await crossApp(
|
||||
calendarActor,
|
||||
contacts.uid,
|
||||
(optConfig) =>
|
||||
target.list({
|
||||
as: 'keys',
|
||||
limit: 2,
|
||||
...(reverse ? { reverse } : {}),
|
||||
...(includeTotal ? { includeTotal } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
optConfig,
|
||||
}),
|
||||
)) as Page;
|
||||
keys.push(...page.items);
|
||||
if (includeTotal)
|
||||
expect(page.total).toBe(PUBLIC.length);
|
||||
cursor = page.cursor;
|
||||
if (cursor) {
|
||||
lengths.add(cursor.length);
|
||||
const position = opened(cursor);
|
||||
expect(position).not.toBeNull();
|
||||
expect(PRIVATE).not.toContain(position!.k);
|
||||
}
|
||||
pages++;
|
||||
} while (cursor && pages < 10);
|
||||
|
||||
expect(keys).toEqual(
|
||||
reverse ? [...PUBLIC].reverse() : PUBLIC,
|
||||
);
|
||||
expect(lengths.size).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('still reads a plain cursor from before they were sealed, and seals the next one', async () => {
|
||||
const { owner, contacts, calendarActor } = await seeded();
|
||||
const namespace = kvNamespace(owner.user.uuid!, contacts.uid);
|
||||
const list = (cursor: unknown, limit = 10) =>
|
||||
crossApp(calendarActor, contacts.uid, (optConfig) =>
|
||||
target.list({ as: 'keys', limit, cursor, optConfig }),
|
||||
) as Promise<Page>;
|
||||
|
||||
expect(
|
||||
(await list(encode({ namespace, key: 'k1' }))).items,
|
||||
).toEqual(PUBLIC.slice(2));
|
||||
expect(
|
||||
(
|
||||
await list(
|
||||
encode({
|
||||
reverse: true,
|
||||
key: { namespace, key: 'k4' },
|
||||
}),
|
||||
)
|
||||
).items,
|
||||
).toEqual(PUBLIC.slice(0, 3).reverse());
|
||||
|
||||
const next = await list(encode({ namespace, key: 'k1' }), 1);
|
||||
expect(next.items).toEqual(['k3-longer']);
|
||||
expect(opened(next.cursor!)).toMatchObject({ k: 'k3-longer' });
|
||||
});
|
||||
|
||||
it('refuses a plain or sealed cursor for another namespace', async () => {
|
||||
const { owner, calendar, contacts, calendarActor } =
|
||||
await seeded();
|
||||
const other = await makeRealApp(owner.user.id!);
|
||||
await writeAs(asApp(owner, other), 'k1');
|
||||
await writeAs(asApp(owner, other), 'k2');
|
||||
await grant(owner, calendar.uid, appDataPermission(other.uid));
|
||||
const fromOther = (
|
||||
(await crossApp(calendarActor, other.uid, (optConfig) =>
|
||||
target.list({ as: 'keys', limit: 1, optConfig }),
|
||||
)) as Page
|
||||
).cursor!;
|
||||
await writeAs(calendarActor, 'own-1');
|
||||
await writeAs(calendarActor, 'own-2');
|
||||
const ownCursor = (
|
||||
(await inCtx(
|
||||
() => target.list({ as: 'keys', limit: 1 }),
|
||||
calendarActor,
|
||||
)) as Page
|
||||
).cursor!;
|
||||
const elsewhere = kvNamespace(owner.user.uuid!, other.uid);
|
||||
const stranger = kvNamespace('someone-else', contacts.uid);
|
||||
|
||||
const forged: unknown[] = [
|
||||
fromOther,
|
||||
encode({ namespace: elsewhere, key: 'k1' }),
|
||||
encode({ namespace: stranger, key: 'k1' }),
|
||||
{ namespace: elsewhere, key: 'k1' },
|
||||
encode({
|
||||
reverse: true,
|
||||
key: { namespace: elsewhere, key: 'k1' },
|
||||
}),
|
||||
encode({
|
||||
namespace: kvNamespace(owner.user.uuid!, contacts.uid),
|
||||
key: 'k1',
|
||||
extra: true,
|
||||
}),
|
||||
sealCursor({ id: 1 }, secret()),
|
||||
sealCursor(
|
||||
{
|
||||
s: elsewhere,
|
||||
k: Buffer.from('k1').toString('base64url'),
|
||||
},
|
||||
secret(),
|
||||
),
|
||||
ownCursor,
|
||||
];
|
||||
for (const cursor of forged)
|
||||
await expect(
|
||||
crossApp(calendarActor, contacts.uid, (optConfig) =>
|
||||
target.list({
|
||||
as: 'keys',
|
||||
limit: 1,
|
||||
cursor,
|
||||
optConfig,
|
||||
}),
|
||||
),
|
||||
JSON.stringify(cursor),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: 'invalid kv: cursor',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses cross-app writes and deletes against a private entry', async () => {
|
||||
const { owner, calendar, contacts, calendarActor, contactsActor } =
|
||||
await setup();
|
||||
|
||||
@@ -32,29 +32,9 @@ import {
|
||||
appDataPermission,
|
||||
appDataSharingAllowed,
|
||||
} from '../../services/permission/appDataScopes.js';
|
||||
import {
|
||||
KV_GLOBAL_APP_KEY,
|
||||
KV_MAX_KEY_BYTES,
|
||||
kvNamespace,
|
||||
type KVOpts,
|
||||
type KVUsage,
|
||||
} from '../../stores/systemKv/SystemKVStore.js';
|
||||
import { KV_SHARE_HANDLE_WIDTHS } from '../../stores/events/columnWidths.js';
|
||||
import type { KVOpts, KVUsage } from '../../stores/systemKv/SystemKVStore.js';
|
||||
import { assertActorHasCredits } from '../../services/metering/enforcement.js';
|
||||
import { isKvHandleId } from '../../services/events/subjects.js';
|
||||
import {
|
||||
KV_HANDLE_KEY_MAX_BYTES,
|
||||
KV_SHARE_PREFIX_MAX_BYTES,
|
||||
kvHandlesDisabled,
|
||||
kvShareLevelForMethod,
|
||||
kvSharePermission,
|
||||
} from '../../services/kvShare/kvShares.js';
|
||||
import {
|
||||
decodeCursor,
|
||||
normalizeLimit,
|
||||
sealCursor,
|
||||
unsealCursor,
|
||||
} from '../../util/pagination.js';
|
||||
import { normalizeLimit } from '../../util/pagination.js';
|
||||
import { KV_COSTS } from './costs.js';
|
||||
import {
|
||||
KV_MAX_BATCH_PUT_ITEMS,
|
||||
@@ -62,143 +42,16 @@ import {
|
||||
KV_MAX_LIST_LIMIT,
|
||||
} from './limits.js';
|
||||
|
||||
/** `optConfig` as every KV method takes it. */
|
||||
interface KvOptConfig {
|
||||
/** Another app's namespace, which the user let this app reach. */
|
||||
appUuid?: string;
|
||||
/** Writes the entry private to the app that wrote it. */
|
||||
disableSharing?: boolean;
|
||||
/**
|
||||
* A share handle: the call reaches that region of its owner's data, with
|
||||
* keys relative to it.
|
||||
*/
|
||||
handle?: string;
|
||||
/** `set` only: write only when no live entry holds the key. */
|
||||
onlyIfAbsent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every KV method's argument object, as far as option resolution cares: the
|
||||
* namespace override, plus the expiry parameters that can delete an entry.
|
||||
*/
|
||||
type KvCallArgs = {
|
||||
optConfig?: KvOptConfig;
|
||||
optConfig?: { appUuid?: string; disableSharing?: boolean };
|
||||
expireAt?: unknown;
|
||||
ttl?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Store options plus, for a call through a share handle, the handle and its
|
||||
* region's prefix: keys go in under it and come back out relative to it.
|
||||
*/
|
||||
type DriverKvOpts = KVOpts & { handle?: string; handlePrefix?: string };
|
||||
|
||||
type KvListResult =
|
||||
| unknown[]
|
||||
| { items: unknown[]; cursor?: string; total?: number };
|
||||
|
||||
/**
|
||||
* A listing of another namespace, cross-app or through a handle: what its
|
||||
* cursors are sealed to, the namespace a position rebuilds in, and the prefix
|
||||
* its keys are relative to.
|
||||
*/
|
||||
interface ForeignListing {
|
||||
bind: string;
|
||||
namespace: string;
|
||||
prefix: string;
|
||||
/** Cross-app cursors were plain before they were sealed. */
|
||||
acceptsPlain: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plaintext bytes a foreign listing's cursor is padded to: a key at the store's
|
||||
* limit in base64url, plus room for the binding and the envelope.
|
||||
*/
|
||||
const FOREIGN_CURSOR_BYTES = Math.ceil((KV_MAX_KEY_BYTES * 4) / 3) + 256;
|
||||
|
||||
/** Whether `value` is a store position, `{ namespace, key }`, in `namespace`. */
|
||||
const isPositionIn = (value: unknown, namespace: string): boolean => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
return false;
|
||||
const { namespace: named, key, ...rest } = value as Record<string, unknown>;
|
||||
return (
|
||||
named === namespace &&
|
||||
typeof key === 'string' &&
|
||||
Object.keys(rest).length === 0
|
||||
);
|
||||
};
|
||||
|
||||
// -- Share-handle errors ----------------------------------------------
|
||||
|
||||
/**
|
||||
* Unknown, retired, not the caller's, owner gone or suspended, or namespace app
|
||||
* deleted: one body for all of them, so no call can tell them apart.
|
||||
*/
|
||||
const handleNotFound = (): HttpError =>
|
||||
new HttpError(404, 'kv: no such share handle', {
|
||||
legacyCode: 'kv_handle_not_found',
|
||||
});
|
||||
|
||||
/** Only ever told to the handle's holder. */
|
||||
const handleAccessDenied = (): HttpError =>
|
||||
new HttpError(403, 'kv: this share handle does not allow this call', {
|
||||
legacyCode: 'kv_handle_access_denied',
|
||||
});
|
||||
|
||||
const handleOptionInvalid = (message: string): HttpError =>
|
||||
new HttpError(400, message, { legacyCode: 'kv_handle_option_invalid' });
|
||||
|
||||
const handleKeyInvalid = (): HttpError =>
|
||||
new HttpError(
|
||||
400,
|
||||
`kv: a key used with a share handle must be a non-empty string of at most ${KV_HANDLE_KEY_MAX_BYTES} bytes`,
|
||||
{ legacyCode: 'kv_handle_key_invalid' },
|
||||
);
|
||||
|
||||
/** The key limit, so no pattern length can tell how long the prefix is. */
|
||||
const handlePatternInvalid = (): HttpError =>
|
||||
new HttpError(
|
||||
400,
|
||||
`kv: a pattern used with a share handle may be at most ${KV_HANDLE_KEY_MAX_BYTES} bytes`,
|
||||
{ legacyCode: 'kv_handle_key_invalid' },
|
||||
);
|
||||
|
||||
const invalidCursor = (): HttpError =>
|
||||
new HttpError(400, 'invalid kv: cursor', { legacyCode: 'bad_request' });
|
||||
|
||||
/** A stored key outside the handle's region, which a scan can't return. */
|
||||
const outsideRegion = (): HttpError =>
|
||||
new HttpError(500, 'kv: internal error', { legacyCode: 'internal_error' });
|
||||
|
||||
const viaHandle = (args?: KvCallArgs): boolean =>
|
||||
args?.optConfig?.handle !== undefined && args.optConfig.handle !== null;
|
||||
|
||||
/** `onlyIfAbsent` as a boolean, refusing anything else a raw body sends. */
|
||||
const parseOnlyIfAbsent = (value: unknown): boolean => {
|
||||
if (value === undefined || value === null || value === false) return false;
|
||||
if (value === true) return true;
|
||||
throw new HttpError(400, 'kv: onlyIfAbsent must be a boolean', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* A store error with every stored key it names put back the way the caller
|
||||
* named it, so the handle's prefix never leaves in a message.
|
||||
*/
|
||||
const relativizeError = (
|
||||
err: unknown,
|
||||
prefix: string,
|
||||
keys: readonly string[],
|
||||
): unknown => {
|
||||
if (!(err instanceof Error) || typeof err.message !== 'string') return err;
|
||||
let message = err.message;
|
||||
for (const key of keys)
|
||||
message = message.split(`${prefix}${key}`).join(key);
|
||||
if (message !== err.message) err.message = message;
|
||||
return err;
|
||||
};
|
||||
|
||||
/**
|
||||
* Methods that stay available to an account with nothing left of its budget.
|
||||
* Each one only ever reduces what the account is storing, and turning those
|
||||
@@ -288,25 +141,13 @@ export class KVStoreDriver extends PuterDriver {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A key as the caller sent it. Through a handle it is relative to the
|
||||
* region, and held to the handle's own fixed limit.
|
||||
*/
|
||||
#coerceKey(key: unknown, args?: KvCallArgs): string {
|
||||
#coerceKey(key: unknown): string {
|
||||
if (key === null || key === undefined) {
|
||||
throw new HttpError(400, 'Missing `key`', {
|
||||
legacyCode: 'bad_request',
|
||||
}); // legacyCode for backward compatibility with old error handling in controllers
|
||||
}
|
||||
const str = typeof key === 'string' ? key : String(key);
|
||||
if (viaHandle(args)) {
|
||||
if (
|
||||
str === '' ||
|
||||
Buffer.byteLength(str, 'utf8') > KV_HANDLE_KEY_MAX_BYTES
|
||||
)
|
||||
throw handleKeyInvalid();
|
||||
return str;
|
||||
}
|
||||
if (str === '')
|
||||
throw new HttpError(400, 'Missing `key`', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -314,38 +155,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
return str;
|
||||
}
|
||||
|
||||
/** The key as stored: through a handle, placed under its prefix. */
|
||||
#storeKey(key: string, opts: DriverKvOpts): string {
|
||||
return opts.handlePrefix === undefined
|
||||
? key
|
||||
: `${opts.handlePrefix}${key}`;
|
||||
}
|
||||
|
||||
/** A stored key as a handle's caller names it; fails closed outside it. */
|
||||
#relativeKey(key: unknown, prefix: string): string {
|
||||
if (typeof key !== 'string' || !key.startsWith(prefix))
|
||||
throw outsideRegion();
|
||||
return key.slice(prefix.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one store call. Through a handle, an error naming a stored key names
|
||||
* the caller's relative key instead.
|
||||
*/
|
||||
async #inRegion<T>(
|
||||
opts: DriverKvOpts,
|
||||
keys: readonly string[],
|
||||
call: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (opts.handlePrefix === undefined) return call();
|
||||
try {
|
||||
return await call();
|
||||
} catch (err) {
|
||||
throw relativizeError(err, opts.handlePrefix, keys);
|
||||
}
|
||||
}
|
||||
|
||||
async #opts(method: string, args: KvCallArgs): Promise<DriverKvOpts> {
|
||||
async #opts(method: string, args: KvCallArgs): Promise<KVOpts> {
|
||||
const actor = Context.get('actor') as Actor | undefined;
|
||||
|
||||
// Every method resolves its options here first, so this is the one
|
||||
@@ -360,8 +170,6 @@ export class KVStoreDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
if (viaHandle(args)) return this.#handleOpts(actor, method, args);
|
||||
|
||||
const appUuid = args.optConfig?.appUuid;
|
||||
// Through the issuer chain, not `actor.app`: an access-token actor
|
||||
// carries no app of its own, so keying off `app` would read a token an
|
||||
@@ -390,214 +198,6 @@ export class KVStoreDriver extends PuterDriver {
|
||||
return { actor, namespaceAppUuid: appUuid };
|
||||
}
|
||||
|
||||
/**
|
||||
* A call through a share handle, resolved to the owner's namespace. The
|
||||
* handle row only names the region and whose it is; the grant, checked here
|
||||
* on every call, is the authority. One check covers every key of the call,
|
||||
* since they all sit under the handle's prefix.
|
||||
*/
|
||||
async #handleOpts(
|
||||
actor: Actor | undefined,
|
||||
method: string,
|
||||
args: KvCallArgs,
|
||||
): Promise<DriverKvOpts> {
|
||||
if (this.config.kv?.shareHandles !== true) throw kvHandlesDisabled();
|
||||
|
||||
const optConfig = args.optConfig ?? {};
|
||||
const level = kvShareLevelForMethod(method);
|
||||
if (optConfig.appUuid || optConfig.disableSharing)
|
||||
throw handleOptionInvalid(
|
||||
'kv: `handle` cannot be combined with `appUuid` or `disableSharing`',
|
||||
);
|
||||
if (level === null)
|
||||
throw handleOptionInvalid(
|
||||
`kv: \`${method}\` is not available through a share handle`,
|
||||
);
|
||||
|
||||
const handle: unknown = optConfig.handle;
|
||||
if (typeof handle !== 'string')
|
||||
throw handleOptionInvalid('kv: `handle` must be a string');
|
||||
const userId = actor?.user?.id;
|
||||
if (
|
||||
!actor ||
|
||||
typeof userId !== 'number' ||
|
||||
handle.length > KV_SHARE_HANDLE_WIDTHS.handle.max ||
|
||||
!isKvHandleId(handle)
|
||||
)
|
||||
throw handleNotFound();
|
||||
|
||||
const live = await this.stores.kvShareHandle.getLive(handle);
|
||||
if (!live || live.ownerSuspended) throw handleNotFound();
|
||||
const { row, ownerUuid } = live;
|
||||
// Bound to its grantee: a handle id is an address, not a bearer
|
||||
// credential, so another holder's overlapping grant doesn't reach it.
|
||||
if (userId !== row.granteeUserId && userId !== row.ownerUserId)
|
||||
throw handleNotFound();
|
||||
|
||||
// Side by side, so the app lookup costs no round trip of its own.
|
||||
const [appGone, allowed] = await Promise.all([
|
||||
row.appUid === KV_GLOBAL_APP_KEY
|
||||
? false
|
||||
: this.stores.app.getByUid(row.appUid).then((app) => !app),
|
||||
this.services.permission.check(
|
||||
actor,
|
||||
kvSharePermission(ownerUuid, row.appUid, row.keyPrefix, level),
|
||||
),
|
||||
]);
|
||||
if (appGone) throw handleNotFound();
|
||||
if (!allowed) {
|
||||
// Asked only on the way out: whoever can still watch the region
|
||||
// is its holder, and only they may learn the level is too low.
|
||||
const holds = await this.services.permission.check(
|
||||
actor,
|
||||
kvSharePermission(
|
||||
ownerUuid,
|
||||
row.appUid,
|
||||
row.keyPrefix,
|
||||
'watch',
|
||||
),
|
||||
);
|
||||
throw holds ? handleAccessDenied() : handleNotFound();
|
||||
}
|
||||
// Past the cap only a watch handle can be, reached through a wider
|
||||
// grant; the fixed key limit holders are shown assumes the cap.
|
||||
if (
|
||||
Buffer.byteLength(row.keyPrefix, 'utf8') > KV_SHARE_PREFIX_MAX_BYTES
|
||||
)
|
||||
throw handleAccessDenied();
|
||||
|
||||
return {
|
||||
actor,
|
||||
namespaceUser: { uuid: ownerUuid, id: row.ownerUserId },
|
||||
namespaceAppUuid: row.appUid,
|
||||
handle,
|
||||
handlePrefix: row.keyPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
/** Set when a listing covers another namespace, cross-app or by handle. */
|
||||
#foreignListing(opts: DriverKvOpts): ForeignListing | undefined {
|
||||
if (opts.handle !== undefined)
|
||||
return {
|
||||
bind: opts.handle,
|
||||
namespace: kvNamespace(
|
||||
opts.namespaceUser!.uuid,
|
||||
opts.namespaceAppUuid!,
|
||||
),
|
||||
prefix: opts.handlePrefix!,
|
||||
acceptsPlain: false,
|
||||
};
|
||||
if (!opts.namespaceAppUuid) return undefined;
|
||||
const namespace = kvNamespace(
|
||||
opts.actor!.user.uuid!,
|
||||
opts.namespaceAppUuid,
|
||||
);
|
||||
return { bind: namespace, namespace, prefix: '', acceptsPlain: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* A foreign listing's cursor as a store position, rebuilt in the listing's
|
||||
* own namespace and region. Only a cursor sealed to this listing opens, or
|
||||
* a plain cross-app one naming the same namespace.
|
||||
*/
|
||||
#cursorIn(
|
||||
cursor: unknown,
|
||||
listing: ForeignListing,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (cursor === undefined || cursor === null) return undefined;
|
||||
if (typeof cursor === 'string') {
|
||||
if (cursor.trim() === '') return undefined;
|
||||
const sealed = unsealCursor(
|
||||
cursor.trim(),
|
||||
this.config.jwt_secret_v2,
|
||||
);
|
||||
if (sealed) {
|
||||
const { s, k, r, ...rest } = sealed;
|
||||
if (
|
||||
s !== listing.bind ||
|
||||
typeof k !== 'string' ||
|
||||
(r !== undefined && r !== true) ||
|
||||
Object.keys(rest).length > 0
|
||||
)
|
||||
throw invalidCursor();
|
||||
const position = {
|
||||
namespace: listing.namespace,
|
||||
key: `${listing.prefix}${Buffer.from(k, 'base64url').toString('utf8')}`,
|
||||
};
|
||||
return r ? { reverse: true, key: position } : position;
|
||||
}
|
||||
} else if (typeof cursor !== 'object') throw invalidCursor();
|
||||
if (!listing.acceptsPlain) throw invalidCursor();
|
||||
|
||||
const plain = decodeCursor(
|
||||
cursor as string | Record<string, unknown>,
|
||||
'kv: cursor',
|
||||
);
|
||||
if (plain === undefined || isPositionIn(plain, listing.namespace))
|
||||
return plain;
|
||||
const { reverse, key, ...rest } = plain;
|
||||
if (
|
||||
reverse === true &&
|
||||
Object.keys(rest).length === 0 &&
|
||||
isPositionIn(key, listing.namespace)
|
||||
)
|
||||
return plain;
|
||||
throw invalidCursor();
|
||||
}
|
||||
|
||||
/**
|
||||
* The store's next-page position, sealed to the listing and padded to one
|
||||
* length, with its key relative to the region.
|
||||
*/
|
||||
#cursorOut(cursor: string, listing: ForeignListing): string | undefined {
|
||||
const decoded = decodeCursor(cursor);
|
||||
const reverse = decoded?.reverse === true;
|
||||
const position = (reverse ? decoded?.key : decoded) as
|
||||
| { namespace?: unknown; key?: unknown }
|
||||
| undefined;
|
||||
if (position?.namespace !== listing.namespace) throw outsideRegion();
|
||||
const key = this.#relativeKey(position.key, listing.prefix);
|
||||
return sealCursor(
|
||||
{
|
||||
s: listing.bind,
|
||||
k: Buffer.from(key, 'utf8').toString('base64url'),
|
||||
...(reverse ? { r: true } : {}),
|
||||
},
|
||||
this.config.jwt_secret_v2,
|
||||
{ padTo: FOREIGN_CURSOR_BYTES },
|
||||
);
|
||||
}
|
||||
|
||||
/** A foreign listing as its caller sees it: keys relative, cursor sealed. */
|
||||
#foreignResult(
|
||||
res: KvListResult,
|
||||
as: string | undefined,
|
||||
listing: ForeignListing,
|
||||
): KvListResult {
|
||||
const { prefix } = listing;
|
||||
const relative = (items: unknown[]): unknown[] => {
|
||||
if (as === 'values' || prefix === '') return items;
|
||||
if (as === 'keys')
|
||||
return items.map((key) => this.#relativeKey(key, prefix));
|
||||
return items.map((entry) => {
|
||||
const { key, value } = entry as {
|
||||
key: unknown;
|
||||
value: unknown;
|
||||
};
|
||||
return { key: this.#relativeKey(key, prefix), value };
|
||||
});
|
||||
};
|
||||
if (Array.isArray(res)) return relative(res);
|
||||
const cursor = res.cursor
|
||||
? this.#cursorOut(res.cursor, listing)
|
||||
: undefined;
|
||||
return {
|
||||
items: relative(res.items),
|
||||
...(cursor ? { cursor } : {}),
|
||||
...(res.total !== undefined ? { total: res.total } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async #assertCrossAppKvAccess(
|
||||
actor: Actor,
|
||||
targetAppUid: string,
|
||||
@@ -722,7 +322,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
|
||||
async get(args: {
|
||||
key: unknown;
|
||||
optConfig?: KvOptConfig;
|
||||
optConfig?: { appUuid?: string };
|
||||
}): Promise<unknown> {
|
||||
const { key } = args;
|
||||
if (key === undefined || key === null) {
|
||||
@@ -743,55 +343,45 @@ export class KVStoreDriver extends PuterDriver {
|
||||
|
||||
if (Array.isArray(key)) {
|
||||
if (key.length === 0) return [];
|
||||
const coerced = key.map((k) => this.#coerceKey(k, args));
|
||||
const { res, usage } = await this.#inRegion(opts, coerced, () =>
|
||||
this.stores.kv.get(
|
||||
{ key: coerced.map((k) => this.#storeKey(k, opts)) },
|
||||
opts,
|
||||
),
|
||||
const coerced = key.map((k) => this.#coerceKey(k));
|
||||
const { res, usage } = await this.stores.kv.get(
|
||||
{ key: coerced },
|
||||
opts,
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
return res;
|
||||
}
|
||||
|
||||
const coerced = this.#coerceKey(key, args);
|
||||
const { res, usage } = await this.#inRegion(opts, [coerced], () =>
|
||||
this.stores.kv.get({ key: this.#storeKey(coerced, opts) }, opts),
|
||||
const { res, usage } = await this.stores.kv.get(
|
||||
{ key: this.#coerceKey(key) },
|
||||
opts,
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* `false` only for an `onlyIfAbsent` write that found a live entry, which
|
||||
* is left as it was.
|
||||
*/
|
||||
async set(args: {
|
||||
key: unknown;
|
||||
value: unknown;
|
||||
expireAt?: number | null;
|
||||
optConfig?: KvOptConfig;
|
||||
optConfig?: { appUuid?: string; disableSharing?: boolean };
|
||||
}): Promise<boolean> {
|
||||
const { key, value, expireAt } = args;
|
||||
const coerced = this.#coerceKey(key, args);
|
||||
const coerced = this.#coerceKey(key);
|
||||
if (value === undefined)
|
||||
throw new HttpError(400, 'Missing `value`', {
|
||||
legacyCode: 'bad_request',
|
||||
}); // legacyCode for backward compatibility with old error handling in controllers
|
||||
const onlyIfAbsent = parseOnlyIfAbsent(args.optConfig?.onlyIfAbsent);
|
||||
|
||||
const opts = await this.#opts('set', args);
|
||||
const { res, usage } = await this.#inRegion(opts, [coerced], () =>
|
||||
this.stores.kv.set(
|
||||
{
|
||||
key: this.#storeKey(coerced, opts),
|
||||
value,
|
||||
expireAt,
|
||||
disableSharing: args.optConfig?.disableSharing,
|
||||
onlyIfAbsent,
|
||||
},
|
||||
opts,
|
||||
),
|
||||
const { res, usage } = await this.stores.kv.set(
|
||||
{
|
||||
key: coerced,
|
||||
value,
|
||||
expireAt,
|
||||
disableSharing: args.optConfig?.disableSharing,
|
||||
},
|
||||
opts,
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
return res;
|
||||
@@ -799,7 +389,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
|
||||
async batchPut(args: {
|
||||
items: Array<{ key: string; value: unknown; expireAt?: number | null }>;
|
||||
optConfig?: KvOptConfig;
|
||||
optConfig?: { appUuid?: string; disableSharing?: boolean };
|
||||
}): Promise<boolean> {
|
||||
const { items } = args;
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
@@ -814,27 +404,17 @@ export class KVStoreDriver extends PuterDriver {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
// A batch write takes no condition, so it can't promise this per item.
|
||||
if (parseOnlyIfAbsent(args.optConfig?.onlyIfAbsent)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'kv: onlyIfAbsent is not available on a batch write',
|
||||
{ legacyCode: 'invalid_request' },
|
||||
);
|
||||
}
|
||||
|
||||
const coerced = items.map((item) => ({
|
||||
key: this.#coerceKey(item.key, args),
|
||||
key: this.#coerceKey(item.key),
|
||||
value: item.value,
|
||||
expireAt: item.expireAt,
|
||||
}));
|
||||
|
||||
const opts = await this.#opts('batchPut', args);
|
||||
// Per-item expiry, which the top-level scan in `#opts` cannot see. A
|
||||
// handle's `write` level already carries expiries.
|
||||
// Per-item expiry, which the top-level scan in `#opts` cannot see.
|
||||
if (
|
||||
opts.namespaceAppUuid &&
|
||||
opts.handlePrefix === undefined &&
|
||||
coerced.some(
|
||||
(item) => item.expireAt !== undefined && item.expireAt !== null,
|
||||
)
|
||||
@@ -844,20 +424,12 @@ export class KVStoreDriver extends PuterDriver {
|
||||
opts.namespaceAppUuid,
|
||||
);
|
||||
}
|
||||
const { res, usage } = await this.#inRegion(
|
||||
const { res, usage } = await this.stores.kv.batchPut(
|
||||
{
|
||||
items: coerced,
|
||||
disableSharing: args.optConfig?.disableSharing,
|
||||
},
|
||||
opts,
|
||||
coerced.map((item) => item.key),
|
||||
() =>
|
||||
this.stores.kv.batchPut(
|
||||
{
|
||||
items: coerced.map((item) => ({
|
||||
...item,
|
||||
key: this.#storeKey(item.key, opts),
|
||||
})),
|
||||
disableSharing: args.optConfig?.disableSharing,
|
||||
},
|
||||
opts,
|
||||
),
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
return res;
|
||||
@@ -865,13 +437,11 @@ export class KVStoreDriver extends PuterDriver {
|
||||
|
||||
async del(args: {
|
||||
key: unknown;
|
||||
optConfig?: KvOptConfig;
|
||||
optConfig?: { appUuid?: string };
|
||||
}): Promise<boolean> {
|
||||
const coerced = this.#coerceKey(args.key, args);
|
||||
const coerced = this.#coerceKey(args.key);
|
||||
const opts = await this.#opts('del', args);
|
||||
const { res, usage } = await this.#inRegion(opts, [coerced], () =>
|
||||
this.stores.kv.del({ key: this.#storeKey(coerced, opts) }, opts),
|
||||
);
|
||||
const { res, usage } = await this.stores.kv.del({ key: coerced }, opts);
|
||||
this.#meter(opts.actor, usage);
|
||||
return res;
|
||||
}
|
||||
@@ -885,7 +455,7 @@ export class KVStoreDriver extends PuterDriver {
|
||||
includeTotal?: boolean;
|
||||
fetchUntilFull?: boolean;
|
||||
reverse?: boolean;
|
||||
optConfig?: KvOptConfig;
|
||||
optConfig?: { appUuid?: string };
|
||||
}): Promise<unknown> {
|
||||
const opts = await this.#opts('list', args);
|
||||
// Naming what it holds is how an account with nothing left decides what
|
||||
@@ -899,14 +469,6 @@ export class KVStoreDriver extends PuterDriver {
|
||||
this.config,
|
||||
);
|
||||
}
|
||||
const handlePrefix = opts.handlePrefix;
|
||||
const listing = this.#foreignListing(opts);
|
||||
if (
|
||||
handlePrefix !== undefined &&
|
||||
typeof args.pattern === 'string' &&
|
||||
Buffer.byteLength(args.pattern, 'utf8') > KV_HANDLE_KEY_MAX_BYTES
|
||||
)
|
||||
throw handlePatternInvalid();
|
||||
const { res, usage } = await this.stores.kv.list(
|
||||
{
|
||||
as: args.as,
|
||||
@@ -914,26 +476,20 @@ export class KVStoreDriver extends PuterDriver {
|
||||
cap: KV_MAX_LIST_LIMIT,
|
||||
label: 'kv: limit',
|
||||
}),
|
||||
cursor: listing
|
||||
? this.#cursorIn(args.cursor, listing)
|
||||
: args.cursor,
|
||||
cursor: args.cursor,
|
||||
pattern: args.pattern,
|
||||
offset: args.offset,
|
||||
includeTotal: args.includeTotal,
|
||||
fetchUntilFull: args.fetchUntilFull,
|
||||
reverse: args.reverse,
|
||||
...(handlePrefix === undefined
|
||||
? {}
|
||||
: { keyPrefix: handlePrefix }),
|
||||
},
|
||||
opts,
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
if (!listing) return res;
|
||||
return this.#foreignResult(res as KvListResult, args.as, listing);
|
||||
return res;
|
||||
}
|
||||
|
||||
async flush(args: { optConfig?: KvOptConfig }): Promise<boolean> {
|
||||
async flush(args: { optConfig?: { appUuid?: string } }): Promise<boolean> {
|
||||
const opts = await this.#opts('flush', args);
|
||||
const { res, usage } = await this.stores.kv.flush(opts);
|
||||
this.#meter(opts.actor, usage);
|
||||
@@ -943,9 +499,9 @@ export class KVStoreDriver extends PuterDriver {
|
||||
async incr(args: {
|
||||
key: unknown;
|
||||
pathAndAmountMap: Record<string, number>;
|
||||
optConfig?: KvOptConfig;
|
||||
optConfig?: { appUuid?: string };
|
||||
}): Promise<unknown> {
|
||||
const coerced = this.#coerceKey(args.key, args);
|
||||
const coerced = this.#coerceKey(args.key);
|
||||
if (
|
||||
!args.pathAndAmountMap ||
|
||||
typeof args.pathAndAmountMap !== 'object'
|
||||
@@ -955,14 +511,9 @@ export class KVStoreDriver extends PuterDriver {
|
||||
});
|
||||
}
|
||||
const opts = await this.#opts('incr', args);
|
||||
const { res, usage } = await this.#inRegion(opts, [coerced], () =>
|
||||
this.stores.kv.incr(
|
||||
{
|
||||
key: this.#storeKey(coerced, opts),
|
||||
pathAndAmountMap: args.pathAndAmountMap,
|
||||
},
|
||||
opts,
|
||||
),
|
||||
const { res, usage } = await this.stores.kv.incr(
|
||||
{ key: coerced, pathAndAmountMap: args.pathAndAmountMap },
|
||||
opts,
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
return res;
|
||||
@@ -971,9 +522,9 @@ export class KVStoreDriver extends PuterDriver {
|
||||
async decr(args: {
|
||||
key: unknown;
|
||||
pathAndAmountMap: Record<string, number>;
|
||||
optConfig?: KvOptConfig;
|
||||
optConfig?: { appUuid?: string };
|
||||
}): Promise<unknown> {
|
||||
const coerced = this.#coerceKey(args.key, args);
|
||||
const coerced = this.#coerceKey(args.key);
|
||||
if (
|
||||
!args.pathAndAmountMap ||
|
||||
typeof args.pathAndAmountMap !== 'object'
|
||||
@@ -983,14 +534,9 @@ export class KVStoreDriver extends PuterDriver {
|
||||
});
|
||||
}
|
||||
const opts = await this.#opts('decr', args);
|
||||
const { res, usage } = await this.#inRegion(opts, [coerced], () =>
|
||||
this.stores.kv.decr(
|
||||
{
|
||||
key: this.#storeKey(coerced, opts),
|
||||
pathAndAmountMap: args.pathAndAmountMap,
|
||||
},
|
||||
opts,
|
||||
),
|
||||
const { res, usage } = await this.stores.kv.decr(
|
||||
{ key: coerced, pathAndAmountMap: args.pathAndAmountMap },
|
||||
opts,
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
return res;
|
||||
@@ -999,23 +545,18 @@ export class KVStoreDriver extends PuterDriver {
|
||||
async expireAt(args: {
|
||||
key: unknown;
|
||||
timestamp: number;
|
||||
optConfig?: KvOptConfig;
|
||||
optConfig?: { appUuid?: string };
|
||||
}): Promise<boolean> {
|
||||
const coerced = this.#coerceKey(args.key, args);
|
||||
const coerced = this.#coerceKey(args.key);
|
||||
if (typeof args.timestamp !== 'number') {
|
||||
throw new HttpError(400, '`timestamp` must be a number', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const opts = await this.#opts('expireAt', args);
|
||||
const { res, usage } = await this.#inRegion(opts, [coerced], () =>
|
||||
this.stores.kv.expireAt(
|
||||
{
|
||||
key: this.#storeKey(coerced, opts),
|
||||
timestamp: args.timestamp,
|
||||
},
|
||||
opts,
|
||||
),
|
||||
const { res, usage } = await this.stores.kv.expireAt(
|
||||
{ key: coerced, timestamp: args.timestamp },
|
||||
opts,
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
return res;
|
||||
@@ -1024,20 +565,18 @@ export class KVStoreDriver extends PuterDriver {
|
||||
async expire(args: {
|
||||
key: unknown;
|
||||
ttl: number;
|
||||
optConfig?: KvOptConfig;
|
||||
optConfig?: { appUuid?: string };
|
||||
}): Promise<boolean> {
|
||||
const coerced = this.#coerceKey(args.key, args);
|
||||
const coerced = this.#coerceKey(args.key);
|
||||
if (typeof args.ttl !== 'number') {
|
||||
throw new HttpError(400, '`ttl` must be a number (seconds)', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const opts = await this.#opts('expire', args);
|
||||
const { res, usage } = await this.#inRegion(opts, [coerced], () =>
|
||||
this.stores.kv.expire(
|
||||
{ key: this.#storeKey(coerced, opts), ttl: args.ttl },
|
||||
opts,
|
||||
),
|
||||
const { res, usage } = await this.stores.kv.expire(
|
||||
{ key: coerced, ttl: args.ttl },
|
||||
opts,
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
return res;
|
||||
@@ -1047,24 +586,22 @@ export class KVStoreDriver extends PuterDriver {
|
||||
key: unknown;
|
||||
pathAndValueMap: Record<string, unknown>;
|
||||
ttl?: number | null;
|
||||
optConfig?: KvOptConfig;
|
||||
optConfig?: { appUuid?: string };
|
||||
}): Promise<unknown> {
|
||||
const coerced = this.#coerceKey(args.key, args);
|
||||
const coerced = this.#coerceKey(args.key);
|
||||
if (!args.pathAndValueMap || typeof args.pathAndValueMap !== 'object') {
|
||||
throw new HttpError(400, 'Missing or invalid `pathAndValueMap`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const opts = await this.#opts('update', args);
|
||||
const { res, usage } = await this.#inRegion(opts, [coerced], () =>
|
||||
this.stores.kv.update(
|
||||
{
|
||||
key: this.#storeKey(coerced, opts),
|
||||
pathAndValueMap: args.pathAndValueMap,
|
||||
ttl: args.ttl,
|
||||
},
|
||||
opts,
|
||||
),
|
||||
const { res, usage } = await this.stores.kv.update(
|
||||
{
|
||||
key: coerced,
|
||||
pathAndValueMap: args.pathAndValueMap,
|
||||
ttl: args.ttl,
|
||||
},
|
||||
opts,
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
return res;
|
||||
@@ -1073,23 +610,18 @@ export class KVStoreDriver extends PuterDriver {
|
||||
async add(args: {
|
||||
key: unknown;
|
||||
pathAndValueMap: Record<string, unknown>;
|
||||
optConfig?: KvOptConfig;
|
||||
optConfig?: { appUuid?: string };
|
||||
}): Promise<unknown> {
|
||||
const coerced = this.#coerceKey(args.key, args);
|
||||
const coerced = this.#coerceKey(args.key);
|
||||
if (!args.pathAndValueMap || typeof args.pathAndValueMap !== 'object') {
|
||||
throw new HttpError(400, 'Missing or invalid `pathAndValueMap`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const opts = await this.#opts('add', args);
|
||||
const { res, usage } = await this.#inRegion(opts, [coerced], () =>
|
||||
this.stores.kv.add(
|
||||
{
|
||||
key: this.#storeKey(coerced, opts),
|
||||
pathAndValueMap: args.pathAndValueMap,
|
||||
},
|
||||
opts,
|
||||
),
|
||||
const { res, usage } = await this.stores.kv.add(
|
||||
{ key: coerced, pathAndValueMap: args.pathAndValueMap },
|
||||
opts,
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
return res;
|
||||
@@ -1098,27 +630,23 @@ export class KVStoreDriver extends PuterDriver {
|
||||
async remove(args: {
|
||||
key: unknown;
|
||||
paths: string[];
|
||||
optConfig?: KvOptConfig;
|
||||
optConfig?: { appUuid?: string };
|
||||
}): Promise<unknown> {
|
||||
const coerced = this.#coerceKey(args.key, args);
|
||||
const coerced = this.#coerceKey(args.key);
|
||||
if (!Array.isArray(args.paths) || args.paths.length === 0) {
|
||||
throw new HttpError(400, 'Missing or invalid `paths`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const opts = await this.#opts('remove', args);
|
||||
const { res, usage } = await this.#inRegion(opts, [coerced], () =>
|
||||
this.stores.kv.remove(
|
||||
{ key: this.#storeKey(coerced, opts), paths: args.paths },
|
||||
opts,
|
||||
),
|
||||
const { res, usage } = await this.stores.kv.remove(
|
||||
{ key: coerced, paths: args.paths },
|
||||
opts,
|
||||
);
|
||||
this.#meter(opts.actor, usage);
|
||||
// The result is the entry's value, so a delete-only grant gets none. A
|
||||
// handle's `write` level reads too.
|
||||
// The result is the entry's value, so a delete-only grant gets none.
|
||||
if (
|
||||
opts.namespaceAppUuid &&
|
||||
opts.handlePrefix === undefined &&
|
||||
!(await this.services.permission.check(
|
||||
opts.actor!,
|
||||
appDataPermission(opts.namespaceAppUuid, 'kv', 'get'),
|
||||
|
||||
Loaded 100 of 435 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user