mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cca509f821 |
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-26
@@ -17,21 +17,6 @@ Out-of-scope:
|
||||
- Denial of Service (DoS), spam, or volumetric attacks.
|
||||
- Physical security issues.
|
||||
|
||||
## Eligibility
|
||||
|
||||
We pay bounties for issues that threaten user security and privacy, or the security and health of the platform. We appreciate every report, but hardening suggestions and correctness bugs are not eligible, even when we fix them. Please open a GitHub issue for those.
|
||||
|
||||
A report must show how an attacker can directly perform the action against another account or the system. It is not eligible if it:
|
||||
|
||||
- Starts from access the attacker should not already have: a stolen session or token, the victim's password, a root or full-access token, or script execution on `puter.com`.
|
||||
- Needs the victim to do more than ordinary use (opening a link or an app), such as publishing their own secrets, sending a verification link to an address they don't control, or approving a prompt that accurately states what it grants.
|
||||
- Gives the attacker nothing they couldn't already get through a supported API.
|
||||
- Only reproduces in a non-default or development setup (SQLite, a `custom` OIDC provider, an unset outbound proxy, cookies or config planted by hand). Findings in the open-source code must affect `puter.com` or a default self-hosted install.
|
||||
|
||||
**Shared state.** Sharing files, KV entries, or cross-app KV access means the sharer trusts the sharee. Exploits that depend on that trust, such as a sharee misusing access the sharer granted, may be treated as hardening or lower severity.
|
||||
|
||||
Hardening we ship in a public PR does not make similar code paths vulnerabilities. They are judged by these same rules.
|
||||
|
||||
## Known Non-Issues (Please Check Before Submitting)
|
||||
|
||||
The following have already been reviewed and determined **not to be vulnerabilities**. Reports that only re-describe one of these are **not eligible for a reward** and will be closed as non-issues — even if they include new code references. Please review this list before submitting:
|
||||
@@ -42,12 +27,11 @@ The following have already been reviewed and determined **not to be vulnerabilit
|
||||
- **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
|
||||
- **App metadata or app user-count "leaks".** This information is currently public by design. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (taskbar items, `/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
|
||||
- **Worker hot-reload driven by a share recipient.** Sharing a worker's source file with write access is how you let a collaborator change that worker's code and have it deploy live. The redeploy running as the file owner, with the owner's worker credentials, is the intended behavior of that grant — the owner chose to hand out write access to executable code. If you do not want that, do not share the source file with write access.
|
||||
- **Re-claiming a site or worker name its owner deleted.** A hosted site or worker name that its owner deletes is treated as abandoned by that owner. If one of their apps still points at it, whoever claims the name next serves that app's `index_url` and receives what the app is launched with, and a new app on the same origin may share the old one's identity. Guarding app identities against being recycled this way is ongoing hardening, not a security boundary, so new ways to re-claim an abandoned name, or to inherit an app's tokens or data through one, are not eligible.
|
||||
- **Share-mode downgrades that do not cascade.** Demoting a delegate does not retroactively revoke the grants that delegate already issued downstream. Shares are independent grants once issued, not a live-evaluated chain. Revoking the downstream grant is a separate, supported action.
|
||||
- **Metering, credit-hold, and quota inaccuracies.** Credit holds and quota checks are up-front estimates, and actual usage is billed afterwards. Overspend that is bounded and still billed is a correctness bug, including concurrent requests that exceed a hold or quota by some margin; the per-account concurrency and rate-limit gates are the control we rely on. Report these only if you can use a service without it being billed, or overspend without bound.
|
||||
- **Existence-only oracles.** A response that tells "exists but forbidden" apart from "not found" for a path or ID the caller already supplied, without revealing anything else about it, is hardening.
|
||||
- **Concurrency / TOCTOU overspend of credits or storage quota.** Firing concurrent requests to exceed an AI credit hold or a storage quota by some margin is known and accepted; the per-account concurrency and rate-limit gates are the control we rely on, and the residual overage is bounded and billed. Report these only if you can demonstrate _unbounded_ overspend.
|
||||
- **Session and credential lifecycle around 2FA enrollment.** Enrolling in 2FA is not a credential-revocation event: existing authenticated sessions stay valid, and outstanding password-reset links stay valid. Likewise, account-management actions behind the `/user-protected/*` gate are password-gated by design, not TOTP-gated. A recovery code that stops being accepted fails _closed_ — it denies access rather than granting it, so it is a bug report, not a vulnerability.
|
||||
- **Group permissions** (`/auth/revoke-user-group` and siblings). The group-permission surface is unfinished and not exposed anywhere in the product. Findings there are not reachable by any user.
|
||||
- **Correctness bugs with no security boundary crossed.** For example, an API that reports success while silently leaving data behind. We want these reported — please open a GitHub issue — but they are not bounty-eligible.
|
||||
- **Missing Subresource Integrity (SRI) on external scripts/styles.** Accepted as hardening, not as a vulnerability, absent a demonstrated compromise of the CDN in question.
|
||||
- **General "token in a URL" / token-lifetime / token-storage designs** — signed directory URLs exposing children, a write signature implying read, app tokens outliving a web session, or the session token being held in `localStorage` on `puter.com`. These are current intended behaviors. Shorter-lived tokens with a refresh flow are on the roadmap; their absence is not a vulnerability on its own. Show a reproducible attack that does not already presuppose script execution on `puter.com`.
|
||||
- **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
|
||||
@@ -68,7 +52,7 @@ To participate, you must:
|
||||
3. **Respect availability**: Do not perform denial-of-service attacks or automated scans that degrade service.
|
||||
4. **Follow disclosure policy**: Do not publicly disclose vulnerabilities until we have confirmed and patched the issue.
|
||||
5. **Act in good faith**: Make every effort to avoid privacy violations, destruction of data, and interruption or degradation of services.
|
||||
6. **Check Eligibility and Known Non-Issues**: Reports that fall outside "Eligibility" or match an item in "Known Non-Issues" above are not eligible and will be closed.
|
||||
6. **Check the Known Non-Issues list**: Reports matching an item in the "Known Non-Issues" section above are not eligible and will be closed.
|
||||
|
||||
Reports that do not meet these guidelines may not be eligible for a reward.
|
||||
|
||||
@@ -79,14 +63,14 @@ Reports **MUST** demonstrate a **working, reproducible exploit with real impact*
|
||||
- Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
|
||||
- The **observed** result versus the **expected** result.
|
||||
- For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
|
||||
- For server-side bugs: a runnable proof-of-concept and screenshot or video showing the attack process. A unit test or in-process harness alone does not count; show it against a running instance.
|
||||
- For server-side bugs: a runnable proof-of-concept and screenshot or video showing the attack process.
|
||||
|
||||
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and not eligible for bounty**.
|
||||
If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting. Reports sent by automated agents are not eligible.
|
||||
If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
|
||||
|
||||
Please submit **one issue per report**.
|
||||
Bundled "audit packs" of many speculative findings will not be eligible for bounty; send each confirmed issue separately.
|
||||
Additionally please **DO NOT** submit too many issues at once; these will be treated as a bundled report, and will not be eligible for bounty.
|
||||
Bundled "audit packs" of many speculative findings will not be elligible for bounty; send each confirmed issue separately.
|
||||
Additionally please **DO NOT** submit too many issues at once; these will be treated as a bundled report, and will not be elligible for bounty.
|
||||
|
||||
## Reporting Process
|
||||
|
||||
@@ -99,8 +83,6 @@ Include:
|
||||
- Suggested remediation (if available)
|
||||
- Paypal id where we can send the bounty (if eligible)
|
||||
|
||||
Send the full report in your first email. We don't reply to messages that only ask whether we run a bounty or where to report.
|
||||
|
||||
We aim to acknowledge receipt within **72 hours** and provide a resolution timeline.
|
||||
|
||||
## Reward Structure
|
||||
@@ -113,7 +95,7 @@ We offer monetary rewards based on the severity of the vulnerability, as determi
|
||||
- **Low: \$50 – \$100**
|
||||
|
||||
Non-security issues, suggestions, and best practices feedback are always welcome, but may not qualify for a reward.
|
||||
If multiple researchers report the same issue, the bounty will be awarded to the first eligible report we receive. Issues we already know about, from internal review, an open ticket, or a fix already in a public commit or PR, count as duplicates.
|
||||
If multiple researchers report the same issue, the bounty will be awarded to the first eligible report we receive.
|
||||
|
||||
## Payments Disclaimer
|
||||
|
||||
|
||||
+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
|
||||
|
||||
|
||||
+6
-16
@@ -7,8 +7,9 @@
|
||||
// Each setting lives at exactly one canonical key — there are no fallback
|
||||
// aliases. Values shown are illustrative, not production secrets.
|
||||
//
|
||||
// Keys consumed only by closed-source / hosted-prod extensions are
|
||||
// intentionally omitted.
|
||||
// Keys consumed only by closed-source / hosted-prod extensions (clickhouse,
|
||||
// cacheUpdateHandler, pages, prodMeteringAndBilling, …) are intentionally
|
||||
// omitted.
|
||||
|
||||
// ── Environment / identity ──────────────────────────────────────────
|
||||
"config_name": "template",
|
||||
@@ -34,7 +35,7 @@
|
||||
// Idle keep-alive timeout in ms. Must stay above the idle timeout of any
|
||||
// proxy in front, or the proxy reuses connections this server has closed
|
||||
// and its clients see 502s.
|
||||
"keep_alive_timeout": 920000,
|
||||
"keep_alive_timeout": 620000,
|
||||
"protocol": "http",
|
||||
"domain": "puter.localhost",
|
||||
// Fully-qualified externally-visible URL. Computed from protocol/domain/
|
||||
@@ -215,9 +216,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 +314,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 +431,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"
|
||||
@@ -455,10 +451,6 @@
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://ark.ap-southeast.bytepluses.com/api/v3"
|
||||
},
|
||||
// Kling AI. One API key (not the legacy AccessKey/SecretKey pair)
|
||||
// powers video generation, image generation and TTS; `apiBaseUrl`
|
||||
// defaults to https://api-singapore.klingai.com.
|
||||
"kling": { "apiKey": "" },
|
||||
"zai": { "apiKey": "" },
|
||||
"alibaba": { "apiKey": "" },
|
||||
"hoonify": {
|
||||
@@ -497,8 +489,6 @@
|
||||
"defaultVoiceId": "",
|
||||
"speechToSpeechModelId": ""
|
||||
},
|
||||
// Both AWS providers also take `"useCredentialChain": true` in place
|
||||
// of keys, like `dynamo.aws`.
|
||||
"aws-polly": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
|
||||
@@ -45,12 +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.
|
||||
|
||||
### 3. puter.js
|
||||
|
||||
- Add the method to the matching module in [src/puter-js/src/modules/](../src/puter-js/src/modules/), matching the calling conventions of its siblings (promise-returning; positional shortcut plus options form where that's the local pattern).
|
||||
|
||||
+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`
|
||||
|
||||
@@ -309,41 +309,4 @@ describe('metering extension — handleMeteringAllCosts', () => {
|
||||
// per request).
|
||||
expect(costsA).toBe(costsB);
|
||||
});
|
||||
|
||||
it('refreshes a warm catalogue at the UTC price boundary', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-11-30T23:59:59Z'));
|
||||
const before = makeRes();
|
||||
await handleMeteringAllCosts(makeReq(), before.res);
|
||||
vi.setSystemTime(new Date('2026-12-01T00:00:00Z'));
|
||||
const after = makeRes();
|
||||
await handleMeteringAllCosts(makeReq(), after.res);
|
||||
expect(
|
||||
(after.captured.body as { costs: unknown[] }).costs,
|
||||
).not.toBe((before.captured.body as { costs: unknown[] }).costs);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// A scheduled price change has to reach the catalogue without a restart.
|
||||
it('collects the catalogue again once the cache expires', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-12-02T00:00:00Z'));
|
||||
const a = makeRes();
|
||||
const b = makeRes();
|
||||
await handleMeteringAllCosts(makeReq(), a.res);
|
||||
vi.setSystemTime(Date.now() + 10 * 60 * 1000 + 1);
|
||||
await handleMeteringAllCosts(makeReq(), b.res);
|
||||
|
||||
const costsA = (a.captured.body as { costs: unknown[] }).costs;
|
||||
const costsB = (b.captured.body as { costs: unknown[] }).costs;
|
||||
expect(costsB).not.toBe(costsA);
|
||||
expect(costsB).toEqual(costsA);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,9 @@ import type { Request, Response } from 'express';
|
||||
const services = extension.import('service');
|
||||
const stores = extension.import('store');
|
||||
|
||||
// Catalogues change on deploy or on a scheduled price change, so the cache
|
||||
// expires rather than living as long as the process.
|
||||
const ALL_COSTS_TTL_MS = 10 * 60 * 1000;
|
||||
// Cached on first request — the underlying cost catalogues are baked into
|
||||
// driver/controller source so they only change on deploy.
|
||||
let cachedAllCosts: Record<string, unknown>[] | null = null;
|
||||
let allCostsExpireAt = 0;
|
||||
|
||||
function collectAllCosts(): Record<string, unknown>[] {
|
||||
const all: Record<string, unknown>[] = [];
|
||||
@@ -189,14 +187,12 @@ export const handleMeteringGlobalUsage = async (
|
||||
res.json(globalUsage);
|
||||
};
|
||||
|
||||
// A hit walks the registries when the in-memory cache is missing or expired.
|
||||
// First hit walks the registries; subsequent hits serve the in-memory cache.
|
||||
export const handleMeteringAllCosts = async (
|
||||
_req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
if (!cachedAllCosts || Date.now() >= allCostsExpireAt) {
|
||||
allCostsExpireAt =
|
||||
(Math.floor(Date.now() / ALL_COSTS_TTL_MS) + 1) * ALL_COSTS_TTL_MS;
|
||||
if (!cachedAllCosts) {
|
||||
const raw = collectAllCosts();
|
||||
const multiplier = creditMultiplierFrom(extension.config);
|
||||
// Price the catalogue in the same unit the usage endpoints report:
|
||||
|
||||
+25
-88
@@ -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 () => {
|
||||
@@ -160,81 +170,6 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(captured.body).toEqual({ error: 'User not found' });
|
||||
});
|
||||
|
||||
describe("an app's view of the email", () => {
|
||||
const appActorFor = async (user: { uuid: string; id: unknown }) => {
|
||||
const app = await (
|
||||
server.stores.app.create as unknown as (
|
||||
fields: Record<string, unknown>,
|
||||
opts: { ownerUserId: number },
|
||||
) => Promise<{ uid: string; id: number }>
|
||||
)(
|
||||
{
|
||||
name: `we-${uuidv4()}`,
|
||||
title: 'Email reader',
|
||||
index_url: `https://${uuidv4()}.example/`,
|
||||
},
|
||||
{ ownerUserId: user.id as number },
|
||||
);
|
||||
return {
|
||||
app,
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: app.uid, id: app.id },
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const whoamiAs = async (actor: Actor) => {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor }, () =>
|
||||
handleWhoami(makeReq(), res),
|
||||
);
|
||||
return captured;
|
||||
};
|
||||
|
||||
it('is withheld until the user grants it, then shown', async () => {
|
||||
const user = await seedUser();
|
||||
const { app, actor } = await appActorFor(user);
|
||||
|
||||
const before = (await whoamiAs(actor)).body as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(before).not.toHaveProperty('email');
|
||||
expect(before).not.toHaveProperty('unconfirmed_email');
|
||||
|
||||
await server.services.permission.grantUserAppPermission(
|
||||
makeActor({ user: { uuid: user.uuid, id: user.id as number } }),
|
||||
app.uid,
|
||||
`user:${user.uuid}:email:read`,
|
||||
);
|
||||
const after = (await whoamiAs(actor)).body as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(after.email).toBe(user.email);
|
||||
expect(after.unconfirmed_email).toBe(user.email);
|
||||
});
|
||||
|
||||
it('answers 404 for a missing account even when the check fails', async () => {
|
||||
const check = vi
|
||||
.spyOn(server.services.permission, 'check')
|
||||
.mockRejectedValue(new Error('scan failed'));
|
||||
try {
|
||||
const captured = await whoamiAs(
|
||||
makeActor({
|
||||
user: { uuid: 'ghost-uuid', id: 99_999_998 },
|
||||
app: { uid: 'app-ghost', id: 1 },
|
||||
}),
|
||||
);
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.body).toEqual({ error: 'User not found' });
|
||||
} finally {
|
||||
check.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('returns full user details for a user actor', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
@@ -516,7 +451,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();
|
||||
|
||||
+5
-18
@@ -36,18 +36,6 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const isUser = !actor.effectiveApp;
|
||||
const canReadEmail = async (uuid: unknown) => {
|
||||
try {
|
||||
return await services.permission.check(
|
||||
actor,
|
||||
`user:${uuid}:email:read`,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// Started beside the row read: the actor carries the row's uuid.
|
||||
const emailCheck = isUser ? null : canReadEmail(actor.user.uuid);
|
||||
const user = await stores.user.getById(actor.user.id);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
@@ -74,10 +62,10 @@ export const handleWhoami = async (
|
||||
|
||||
// Strip sensitive fields for app actors
|
||||
if (!isUser) {
|
||||
const emailReadable = await (user.uuid === actor.user.uuid
|
||||
? emailCheck
|
||||
: canReadEmail(user.uuid));
|
||||
if (!emailReadable) {
|
||||
const canReadEmail = await services.permission
|
||||
.check(actor, `user:${user.uuid}:email:read`)
|
||||
.catch(() => false);
|
||||
if (!canReadEmail) {
|
||||
delete details.email;
|
||||
delete details.unconfirmed_email;
|
||||
}
|
||||
@@ -121,8 +109,7 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const subscription = details.subscription as
|
||||
| { offering?: Record<string, unknown> }
|
||||
| undefined;
|
||||
{ offering?: Record<string, unknown> } | undefined;
|
||||
if (subscription?.offering) {
|
||||
delete subscription.offering.group;
|
||||
delete subscription.offering.benefits;
|
||||
|
||||
Generated
+99
-111
@@ -16,7 +16,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.15",
|
||||
"libphonenumber-js": "1.13.14",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
@@ -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",
|
||||
@@ -5932,9 +5921,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/busboy": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.5.tgz",
|
||||
"integrity": "sha512-IwqrfVFxWa23c4WeE7uZswssCqGfvnl5Q7b1pdshbT3HaQDa3UIbJx5BAxtcj9nZYX9tVDArM9TUv9SpvORbpQ==",
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz",
|
||||
"integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -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",
|
||||
@@ -11534,9 +11523,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.13.15",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.15.tgz",
|
||||
"integrity": "sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==",
|
||||
"version": "1.13.14",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.14.tgz",
|
||||
"integrity": "sha512-llihgCcx0BFLksecLP+x1J+6JDE1GsXS1RN/LoPF6qcwpeQcnjj0lcvZxY8AzbEpYwyZWPZW/nDuqkqzm3amiw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
@@ -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",
|
||||
@@ -14378,9 +14367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz",
|
||||
"integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==",
|
||||
"version": "3.9.9",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
|
||||
"integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -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.17",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.17.tgz",
|
||||
"integrity": "sha512-/9RwyNTcR8osMQWOvC8NV8l57ddPyYXvuG+wa1Ua4cD/I41z7zCMsGtXLa9vxlrggjRrfejr50X0WS/9NuVWqQ==",
|
||||
"license": "MIT-0",
|
||||
"dependencies": {
|
||||
"ipv6-normalize": "1.0.1",
|
||||
"nodemailer": "10.1.2",
|
||||
"nodemailer": "10.0.14",
|
||||
"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.0.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.14.tgz",
|
||||
"integrity": "sha512-eJoLFKg55fesSlzUekmfOv/SBXh4KUYT8oiQc2RLP0tfG0rbNMJBK7wgHJwXTJwqyKlSJMz9UzDZ4v+aHEvo8g==",
|
||||
"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"
|
||||
@@ -16742,9 +16730,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.3.tgz",
|
||||
"integrity": "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==",
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
|
||||
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
@@ -16787,9 +16775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.6.tgz",
|
||||
"integrity": "sha512-aRAHIxx5SW5zc1sMqFwJU/t5qaFfXb5Hj0J7W0VXihu/X99+346tz6080tO1T1xVIJORX/b3zVTXGFqUXh35uw==",
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -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,19 +17712,19 @@
|
||||
"postal-mime": "^3.0.1",
|
||||
"replicate": "^1.0.0",
|
||||
"sharp": "^0.35.5",
|
||||
"smtp-server": "^3.19.23",
|
||||
"smtp-server": "^3.19.17",
|
||||
"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",
|
||||
"uuid": "^14.0.3",
|
||||
"undici": "^7.29.1",
|
||||
"uuid": "^14.0.2",
|
||||
"validator": "^13.15.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/busboy": "^1.5.5",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
@@ -17747,7 +17735,7 @@
|
||||
"@types/validator": "^13.15.10",
|
||||
"pgmock": "^1.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.1.6",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.0.14"
|
||||
}
|
||||
},
|
||||
@@ -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
-1
@@ -77,7 +77,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.15",
|
||||
"libphonenumber-js": "1.13.14",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -28,12 +28,6 @@ export interface WriteResult {
|
||||
insertId: number | bigint;
|
||||
affectedRows: number;
|
||||
anyRowsAffected: boolean;
|
||||
/**
|
||||
* Warnings the statement raised; nonzero means a value may have been stored
|
||||
* differently from how it was sent. Absent when the engine can't vouch that
|
||||
* a clean insert stored exactly what was sent, on a fresh id.
|
||||
*/
|
||||
warningStatus?: number;
|
||||
}
|
||||
|
||||
export interface BatchEntry {
|
||||
@@ -336,30 +330,6 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole `periodSeconds` buckets since the epoch, from a unix-seconds
|
||||
* column. MySQL's `/` yields a decimal, which would split a bucket.
|
||||
*/
|
||||
periodBucket(secondsExpression: string, periodSeconds: number): string {
|
||||
return this.case({
|
||||
mysql: `(${secondsExpression} DIV ${periodSeconds})`,
|
||||
otherwise: `(${secondsExpression} / ${periodSeconds})`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts each (`userExpression`, bucket) pair once. `COUNT(DISTINCT a, b)`
|
||||
* is MySQL-only, so the pair folds into one number — injective while the
|
||||
* bucket stays under the multiplier, which hourly reaches in the 2980s.
|
||||
*/
|
||||
countDistinctUserPeriods(
|
||||
userExpression: string,
|
||||
secondsExpression: string,
|
||||
periodSeconds: number,
|
||||
): string {
|
||||
return `COUNT(DISTINCT ${userExpression} * 10000000 + ${this.periodBucket(secondsExpression, periodSeconds)})`;
|
||||
}
|
||||
|
||||
nullCoalesce(...expressions: readonly string[]): string {
|
||||
if (expressions.length === 0) {
|
||||
throw new Error('nullCoalesce requires at least one expression');
|
||||
|
||||
@@ -435,105 +435,6 @@ describe('MySQLDatabaseClient — query interface', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ method: 'pread', poolIndex: 0, maxTimeInQueue: 30 },
|
||||
{ method: 'read', poolIndex: 1, maxTimeInQueue: 10 },
|
||||
] as const)(
|
||||
'$method: flushes lone reads at once until 8 are in flight, then waits $maxTimeInQueue ms and coalesces',
|
||||
async ({ method, poolIndex, maxTimeInQueue }) => {
|
||||
const client = await startClient({ replica: { host: 'replica' } });
|
||||
const pool = createdPools[poolIndex];
|
||||
const held: Array<() => void> = [];
|
||||
pool.respond = (sql) =>
|
||||
new Promise((resolve) => {
|
||||
const sets = sql
|
||||
.split(';')
|
||||
.slice(0, -1)
|
||||
.map((stmt) => [{ stmt }]);
|
||||
held.push(() => resolve(rowSets(...sets)));
|
||||
});
|
||||
vi.useFakeTimers({
|
||||
toFake: [
|
||||
'setTimeout',
|
||||
'clearTimeout',
|
||||
'setImmediate',
|
||||
'clearImmediate',
|
||||
],
|
||||
});
|
||||
try {
|
||||
const reads: Promise<unknown>[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
reads.push(client[method](`SELECT ${i}`));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
expect(pool.calls).toHaveLength(8);
|
||||
|
||||
reads.push(client[method]('SELECT 8'));
|
||||
await vi.advanceTimersByTimeAsync(maxTimeInQueue - 1);
|
||||
reads.push(client[method]('SELECT 9'));
|
||||
expect(pool.calls).toHaveLength(8);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pool.calls.map((c) => c.sql).at(-1)).toBe(
|
||||
'SELECT 8;SELECT 9; SELECT 1',
|
||||
);
|
||||
|
||||
for (const release of held.splice(0)) release();
|
||||
const rows = await Promise.all(reads);
|
||||
expect(rows.at(-2)).toEqual([{ stmt: 'SELECT 8' }]);
|
||||
expect(rows.at(-1)).toEqual([{ stmt: 'SELECT 9' }]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ setup: 'single node', replica: undefined, waits: true },
|
||||
{ setup: 'replica', replica: { host: 'replica' }, waits: false },
|
||||
])(
|
||||
'$setup: 8 primary reads in flight hold back a lone read: $waits',
|
||||
async ({ replica, waits }) => {
|
||||
const client = await startClient(replica ? { replica } : {});
|
||||
const held: Array<() => void> = [];
|
||||
for (const pool of createdPools) {
|
||||
pool.respond = (sql) =>
|
||||
new Promise((resolve) => {
|
||||
const sets = sql
|
||||
.split(';')
|
||||
.slice(0, -1)
|
||||
.map((stmt) => [{ stmt }]);
|
||||
held.push(() => resolve(rowSets(...sets)));
|
||||
});
|
||||
}
|
||||
const sent = () => createdPools.flatMap((pool) => pool.calls);
|
||||
vi.useFakeTimers({
|
||||
toFake: [
|
||||
'setTimeout',
|
||||
'clearTimeout',
|
||||
'setImmediate',
|
||||
'clearImmediate',
|
||||
],
|
||||
});
|
||||
try {
|
||||
const reads: Promise<unknown>[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
reads.push(client.pread(`SELECT ${i}`));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
reads.push(client.read('SELECT 8'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sent()).toHaveLength(waits ? 8 : 9);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(sent()).toHaveLength(9);
|
||||
|
||||
for (const release of held.splice(0)) release();
|
||||
await Promise.all(reads);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('sends a write on its own, outside a transaction', async () => {
|
||||
const client = await startClient();
|
||||
const pool = createdPools[0];
|
||||
@@ -571,23 +472,6 @@ describe('MySQLDatabaseClient — query interface', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the warning count through, so callers can tell a clean write', async () => {
|
||||
const client = await startClient();
|
||||
createdPools[0].respond = () =>
|
||||
writeHeader({ insertId: 9, affectedRows: 1, warningStatus: 1 });
|
||||
|
||||
await expect(
|
||||
client.write('INSERT IGNORE INTO `user` (`username`) VALUES (?)', [
|
||||
'ada',
|
||||
]),
|
||||
).resolves.toEqual({
|
||||
insertId: 9,
|
||||
affectedRows: 1,
|
||||
anyRowsAffected: true,
|
||||
warningStatus: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports no rows affected when the header omits the counters', async () => {
|
||||
const client = await startClient();
|
||||
createdPools[0].respond = () => writeHeader({});
|
||||
|
||||
@@ -78,13 +78,8 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
});
|
||||
console.log('[mysql] connected to primary');
|
||||
|
||||
// Read batchers on one pool share the cap on immediate flushes.
|
||||
const primaryReadFlushes = { count: 0 };
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
this.dbPrimaryRead = this.createPrimaryReadBatcher(
|
||||
this.primaryPool,
|
||||
primaryReadFlushes,
|
||||
);
|
||||
this.dbPrimaryRead = this.createPrimaryReadBatcher(this.primaryPool);
|
||||
|
||||
if (dbConf.replica) {
|
||||
this.replicaPool = this.createPool(dbConf.replica);
|
||||
@@ -95,12 +90,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
this.configuration = Configuration.SINGLE;
|
||||
}
|
||||
|
||||
this.dbReplica = this.createReplicaBatcher(
|
||||
this.replicaPool,
|
||||
this.configuration === Configuration.SINGLE
|
||||
? primaryReadFlushes
|
||||
: { count: 0 },
|
||||
);
|
||||
this.dbReplica = this.createReplicaBatcher(this.replicaPool);
|
||||
|
||||
await this.applyMigrationPaths(
|
||||
() => this.primaryPool.promise().getConnection(),
|
||||
@@ -174,14 +164,12 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
const header = result[0] as {
|
||||
insertId?: number;
|
||||
affectedRows?: number;
|
||||
warningStatus?: number;
|
||||
};
|
||||
const affectedRows = header.affectedRows ?? 0;
|
||||
return {
|
||||
insertId: header.insertId ?? 0,
|
||||
affectedRows,
|
||||
anyRowsAffected: affectedRows > 0,
|
||||
warningStatus: header.warningStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -286,14 +274,9 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
}
|
||||
|
||||
/** Reads that must see the primary, still coalesced. */
|
||||
private createPrimaryReadBatcher(
|
||||
pool: Pool,
|
||||
inFlight: { count: number },
|
||||
): SQLBatcher {
|
||||
private createPrimaryReadBatcher(pool: Pool): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 30,
|
||||
maxInFlightImmediate: 8,
|
||||
inFlight,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'primary',
|
||||
readOnly: true,
|
||||
@@ -301,14 +284,9 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
private createReplicaBatcher(
|
||||
pool: Pool,
|
||||
inFlight: { count: number },
|
||||
): SQLBatcher {
|
||||
private createReplicaBatcher(pool: Pool): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 10,
|
||||
maxInFlightImmediate: 8,
|
||||
inFlight,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'replica',
|
||||
readOnly: true,
|
||||
|
||||
@@ -76,7 +76,6 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
export class SQLBatcher {
|
||||
dbPool;
|
||||
maxTimeInQueue;
|
||||
maxInFlightImmediate;
|
||||
maxBatchSize;
|
||||
maxQueueSize;
|
||||
failureThreshold;
|
||||
@@ -85,9 +84,7 @@ export class SQLBatcher {
|
||||
readOnly;
|
||||
acquireTimeoutMs;
|
||||
queue = [];
|
||||
#flushTimer = null;
|
||||
#flushTimerIsImmediate = false;
|
||||
#inFlight;
|
||||
timeouts = [];
|
||||
#consecutiveFailures = 0;
|
||||
#lastFailureAt = 0;
|
||||
#metricAttrs;
|
||||
@@ -96,11 +93,6 @@ export class SQLBatcher {
|
||||
* @param {object} dbPool Mysql2 pool
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxTimeInQueue] Ms an item may wait before flush
|
||||
* @param {number} [opts.maxInFlightImmediate] While fewer flushes than this
|
||||
* are in flight, a lone item flushes on the next turn instead of waiting
|
||||
* maxTimeInQueue; past it, items wait and coalesce to spare connections
|
||||
* @param {{ count: number }} [opts.inFlight] In-flight flush counter the
|
||||
* cap reads; batchers sharing a pool can share one
|
||||
* @param {number} [opts.maxBatchSize] Items coalesced per flush
|
||||
* @param {number} [opts.maxQueueSize] Drop-oldest high-water mark
|
||||
* @param {number} [opts.failureThreshold] Consecutive failures to open the
|
||||
@@ -119,8 +111,6 @@ export class SQLBatcher {
|
||||
dbPool,
|
||||
{
|
||||
maxTimeInQueue = 20,
|
||||
maxInFlightImmediate = 0,
|
||||
inFlight = { count: 0 },
|
||||
maxBatchSize = 50,
|
||||
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
||||
failureThreshold = DEFAULT_FAILURE_THRESHOLD,
|
||||
@@ -132,8 +122,6 @@ export class SQLBatcher {
|
||||
) {
|
||||
this.dbPool = dbPool;
|
||||
this.maxTimeInQueue = maxTimeInQueue;
|
||||
this.maxInFlightImmediate = maxInFlightImmediate;
|
||||
this.#inFlight = inFlight;
|
||||
this.maxBatchSize = maxBatchSize;
|
||||
this.maxQueueSize = maxQueueSize;
|
||||
this.failureThreshold = failureThreshold;
|
||||
@@ -197,34 +185,17 @@ export class SQLBatcher {
|
||||
|
||||
if (this.queue.length >= this.maxBatchSize) {
|
||||
this.flush(this.queue.splice(0, this.maxBatchSize));
|
||||
} else if (this.#flushTimer === null) {
|
||||
this.#armFlushTimer();
|
||||
} else if (this.queue.length === 1) {
|
||||
this.timeouts.push(
|
||||
setTimeout(() => {
|
||||
this.flush(this.queue.splice(0, this.queue.length));
|
||||
}, this.maxTimeInQueue),
|
||||
);
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
// A lone flush holds a pooled connection for a full round trip, so only
|
||||
// the first maxInFlightImmediate flushes skip the wait.
|
||||
#armFlushTimer() {
|
||||
const run = () => {
|
||||
this.#flushTimer = null;
|
||||
this.flush(this.queue.splice(0, this.queue.length));
|
||||
};
|
||||
this.#flushTimerIsImmediate =
|
||||
this.#inFlight.count < this.maxInFlightImmediate;
|
||||
this.#flushTimer = this.#flushTimerIsImmediate
|
||||
? setImmediate(run)
|
||||
: setTimeout(run, this.maxTimeInQueue);
|
||||
}
|
||||
|
||||
#clearFlushTimer() {
|
||||
if (this.#flushTimer === null) return;
|
||||
if (this.#flushTimerIsImmediate) clearImmediate(this.#flushTimer);
|
||||
else clearTimeout(this.#flushTimer);
|
||||
this.#flushTimer = null;
|
||||
}
|
||||
|
||||
// Bounded wait for a pooled connection. Without a bound, a stalled
|
||||
// database turns every flush into an indefinite hang — nothing fails,
|
||||
// so neither the breaker nor callers' own timeouts ever engage.
|
||||
@@ -281,18 +252,12 @@ export class SQLBatcher {
|
||||
}
|
||||
|
||||
async flush(batch) {
|
||||
this.#clearFlushTimer();
|
||||
const timeout = this.timeouts.shift();
|
||||
if (timeout && !timeout._destroyed) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
if (batch.length === 0) return;
|
||||
|
||||
this.#inFlight.count++;
|
||||
try {
|
||||
await this.#flushBatch(batch);
|
||||
} finally {
|
||||
this.#inFlight.count--;
|
||||
}
|
||||
}
|
||||
|
||||
async #flushBatch(batch) {
|
||||
let connection;
|
||||
try {
|
||||
connection = await this.#acquireConnection();
|
||||
|
||||
@@ -69,356 +69,6 @@ const happyBatch = (sql: string) => {
|
||||
];
|
||||
};
|
||||
|
||||
// Each query on this connection waits until the test releases it, so its
|
||||
// flush stays in flight.
|
||||
const makeHeldConnection = () => {
|
||||
const held: Array<() => void> = [];
|
||||
const conn = makeConnection(
|
||||
(sql) =>
|
||||
new Promise((resolve) => {
|
||||
held.push(() => resolve(happyBatch(sql)));
|
||||
}),
|
||||
);
|
||||
const releaseAll = () => {
|
||||
for (const release of held.splice(0)) release();
|
||||
};
|
||||
const sent = () => conn.query.mock.calls.map(([sql]) => sql);
|
||||
return { conn, releaseAll, sent };
|
||||
};
|
||||
|
||||
const fakeFlushTimers = () =>
|
||||
vi.useFakeTimers({
|
||||
toFake: [
|
||||
'setTimeout',
|
||||
'clearTimeout',
|
||||
'setImmediate',
|
||||
'clearImmediate',
|
||||
],
|
||||
});
|
||||
|
||||
describe('SQLBatcher flush timing', () => {
|
||||
it('flushes a lone query on the next turn while under the in-flight cap', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool, getConnection } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
readOnly: true,
|
||||
maxTimeInQueue: 60_000,
|
||||
maxInFlightImmediate: 2,
|
||||
});
|
||||
|
||||
const pending = batcher.query('SELECT a', []);
|
||||
expect(getConnection).not.toHaveBeenCalled();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(getConnection).toHaveBeenCalledTimes(1);
|
||||
expect((await pending)[0]).toEqual([{ n: 0 }]);
|
||||
});
|
||||
|
||||
it('holds a lone query for maxTimeInQueue without an in-flight cap', async () => {
|
||||
fakeFlushTimers();
|
||||
try {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool, getConnection } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
readOnly: true,
|
||||
maxTimeInQueue: 30,
|
||||
});
|
||||
|
||||
const pending = batcher.query('SELECT a', []);
|
||||
await vi.advanceTimersByTimeAsync(29);
|
||||
expect(getConnection).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(getConnection).toHaveBeenCalledTimes(1);
|
||||
expect((await pending)[0]).toEqual([{ n: 0 }]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('batches queries from the same turn and sends later ones separately', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool, getConnection } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
readOnly: true,
|
||||
maxInFlightImmediate: 8,
|
||||
});
|
||||
|
||||
const sameTurn = [
|
||||
batcher.query('SELECT a', []),
|
||||
batcher.query('SELECT b', []),
|
||||
batcher.query('SELECT c', []),
|
||||
];
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const nextTurn = batcher.query('SELECT d', []);
|
||||
const results = await Promise.all([...sameTurn, nextTurn]);
|
||||
|
||||
expect(results.map((r) => r[0])).toEqual([
|
||||
[{ n: 0 }],
|
||||
[{ n: 1 }],
|
||||
[{ n: 2 }],
|
||||
[{ n: 0 }],
|
||||
]);
|
||||
expect(getConnection).toHaveBeenCalledTimes(2);
|
||||
expect(conn.query.mock.calls.map(([sql]) => sql)).toEqual([
|
||||
'SELECT a;SELECT b;SELECT c; SELECT 1',
|
||||
'SELECT d; SELECT 1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a query enqueued during an in-flight flush its own flush while under the cap', async () => {
|
||||
const { conn, releaseAll, sent } = makeHeldConnection();
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
readOnly: true,
|
||||
maxInFlightImmediate: 2,
|
||||
});
|
||||
const turn = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const a = batcher.query('SELECT a', []);
|
||||
await turn();
|
||||
const b = batcher.query('SELECT b', []);
|
||||
await turn();
|
||||
expect(sent()).toEqual(['SELECT a; SELECT 1', 'SELECT b; SELECT 1']);
|
||||
|
||||
releaseAll();
|
||||
expect((await a)[0]).toEqual([{ n: 0 }]);
|
||||
expect((await b)[0]).toEqual([{ n: 0 }]);
|
||||
});
|
||||
|
||||
it('holds a lone query for maxTimeInQueue at the in-flight cap and coalesces what follows', async () => {
|
||||
fakeFlushTimers();
|
||||
try {
|
||||
const { conn, releaseAll, sent } = makeHeldConnection();
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
readOnly: true,
|
||||
maxTimeInQueue: 30,
|
||||
maxInFlightImmediate: 1,
|
||||
});
|
||||
|
||||
const a = batcher.query('SELECT a', []);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sent()).toEqual(['SELECT a; SELECT 1']);
|
||||
|
||||
const b = batcher.query('SELECT b', []);
|
||||
await vi.advanceTimersByTimeAsync(29);
|
||||
const c = batcher.query('SELECT c', []);
|
||||
expect(sent()).toHaveLength(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(sent()).toEqual([
|
||||
'SELECT a; SELECT 1',
|
||||
'SELECT b;SELECT c; SELECT 1',
|
||||
]);
|
||||
|
||||
releaseAll();
|
||||
expect((await a)[0]).toEqual([{ n: 0 }]);
|
||||
expect((await b)[0]).toEqual([{ n: 0 }]);
|
||||
expect((await c)[0]).toEqual([{ n: 1 }]);
|
||||
|
||||
// Settled flushes free the cap again.
|
||||
const d = batcher.query('SELECT d', []);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sent()).toHaveLength(3);
|
||||
releaseAll();
|
||||
expect((await d)[0]).toEqual([{ n: 0 }]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('shares the in-flight cap between batchers given one counter', async () => {
|
||||
fakeFlushTimers();
|
||||
try {
|
||||
const { conn, releaseAll, sent } = makeHeldConnection();
|
||||
const { pool } = makePool(conn);
|
||||
const inFlight = { count: 0 };
|
||||
const opts = {
|
||||
readOnly: true,
|
||||
maxTimeInQueue: 30,
|
||||
maxInFlightImmediate: 1,
|
||||
inFlight,
|
||||
};
|
||||
const first = new SQLBatcher(pool, opts);
|
||||
const second = new SQLBatcher(pool, opts);
|
||||
const own = new SQLBatcher(pool, { ...opts, inFlight: undefined });
|
||||
|
||||
const a = first.query('SELECT a', []);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const b = second.query('SELECT b', []);
|
||||
const c = own.query('SELECT c', []);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sent()).toEqual([
|
||||
'SELECT a; SELECT 1',
|
||||
'SELECT c; SELECT 1',
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
expect(sent()).toHaveLength(3);
|
||||
expect(sent()[2]).toBe('SELECT b; SELECT 1');
|
||||
|
||||
releaseAll();
|
||||
await Promise.all([a, b, c]);
|
||||
expect(inFlight.count).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('counts a flush as in flight until its per-item fallback settles', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
fakeFlushTimers();
|
||||
try {
|
||||
const held: Array<() => void> = [];
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ECONNRESET');
|
||||
return new Promise((resolve) => {
|
||||
held.push(() => resolve([[{ ok: sql }], undefined]));
|
||||
});
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const sent = () => conn.query.mock.calls.map(([sql]) => sql);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
readOnly: true,
|
||||
maxTimeInQueue: 30,
|
||||
maxInFlightImmediate: 1,
|
||||
acquireTimeoutMs: 0,
|
||||
});
|
||||
|
||||
const a = batcher.query('SELECT a', []);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
// The batch failed; the per-item retry of `a` is still running.
|
||||
expect(sent()).toEqual(['SELECT a; SELECT 1', 'SELECT a']);
|
||||
|
||||
const b = batcher.query('SELECT b', []);
|
||||
await vi.advanceTimersByTimeAsync(29);
|
||||
expect(sent()).toHaveLength(2);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(sent()).toEqual([
|
||||
'SELECT a; SELECT 1',
|
||||
'SELECT a',
|
||||
'SELECT b; SELECT 1',
|
||||
'SELECT b',
|
||||
]);
|
||||
|
||||
for (const release of held.splice(0)) release();
|
||||
expect((await a)[0]).toEqual([{ ok: 'SELECT a' }]);
|
||||
expect((await b)[0]).toEqual([{ ok: 'SELECT b' }]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('flushes a full batch at once past the cap and cancels the armed wait', async () => {
|
||||
fakeFlushTimers();
|
||||
try {
|
||||
const { conn, releaseAll, sent } = makeHeldConnection();
|
||||
const { pool, getConnection } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
readOnly: true,
|
||||
maxBatchSize: 2,
|
||||
maxTimeInQueue: 30,
|
||||
maxInFlightImmediate: 1,
|
||||
acquireTimeoutMs: 0,
|
||||
});
|
||||
|
||||
const a = batcher.query('SELECT a', []);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const b = batcher.query('SELECT b', []);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
const c = batcher.query('SELECT c', []);
|
||||
expect(getConnection).toHaveBeenCalledTimes(2);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
// A later lone query waits its own full maxTimeInQueue.
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
const d = batcher.query('SELECT d', []);
|
||||
await vi.advanceTimersByTimeAsync(29);
|
||||
expect(getConnection).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(getConnection).toHaveBeenCalledTimes(3);
|
||||
expect(sent()).toEqual([
|
||||
'SELECT a; SELECT 1',
|
||||
'SELECT b;SELECT c; SELECT 1',
|
||||
'SELECT d; SELECT 1',
|
||||
]);
|
||||
|
||||
releaseAll();
|
||||
await Promise.all([a, b, c, d]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels the pending next-turn flush when a batch fills up', async () => {
|
||||
fakeFlushTimers();
|
||||
try {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool, getConnection } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxBatchSize: 2,
|
||||
readOnly: true,
|
||||
maxInFlightImmediate: 8,
|
||||
acquireTimeoutMs: 0,
|
||||
});
|
||||
|
||||
const a = batcher.query('SELECT a', []);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
const b = batcher.query('SELECT b', []);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
const c = batcher.query('SELECT c', []);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.all([a, b, c]);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(getConnection).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves no timer armed after a failed flush, a drop or a breaker rejection', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
fakeFlushTimers();
|
||||
try {
|
||||
const getConnection = vi.fn(async () => {
|
||||
throw makeError('ECONNREFUSED');
|
||||
});
|
||||
const batcher = new SQLBatcher(
|
||||
{ promise: () => ({ getConnection }) },
|
||||
{
|
||||
readOnly: true,
|
||||
maxQueueSize: 1,
|
||||
failureThreshold: 1,
|
||||
cooldownMs: 60_000,
|
||||
maxInFlightImmediate: 8,
|
||||
},
|
||||
);
|
||||
|
||||
const dropped = expect(
|
||||
batcher.query('SELECT a', []),
|
||||
).rejects.toMatchObject({ reason: 'queueOverflow' });
|
||||
const failed = expect(
|
||||
batcher.query('SELECT b', []),
|
||||
).rejects.toMatchObject({ reason: 'connAcquire' });
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
await vi.runAllTimersAsync();
|
||||
await dropped;
|
||||
await failed;
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
await expect(batcher.query('SELECT c', [])).rejects.toMatchObject({
|
||||
reason: 'breakerOpen',
|
||||
});
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(batcher.queue).toHaveLength(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SQLBatcher', () => {
|
||||
it('resolves each batched item with its own result', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
|
||||
@@ -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 = 89;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -930,7 +930,6 @@ describe('SqliteDatabaseClient — query interface', () => {
|
||||
affectedRows: 1,
|
||||
anyRowsAffected: true,
|
||||
});
|
||||
expect(inserted).not.toHaveProperty('warningStatus');
|
||||
|
||||
const missed = await client.write(
|
||||
'UPDATE `widget` SET `name` = ? WHERE `id` = ?',
|
||||
|
||||
@@ -128,8 +128,6 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[86, ['0091_icloud-dot-significance.sql']],
|
||||
[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 {
|
||||
@@ -208,9 +206,6 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
insertId: info.lastInsertRowid,
|
||||
affectedRows: info.changes,
|
||||
anyRowsAffected: info.changes > 0,
|
||||
// No warningStatus: SQLite raises none, but a new row can reuse a
|
||||
// deleted row's rowid that other tables still name, so a clean
|
||||
// insert doesn't prove the row is all the caller sent.
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +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/>.
|
||||
|
||||
-- Uid and owner of each deleted app. Mirrors SQLite migration 0094.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `deleted_apps` (
|
||||
`uid` VARCHAR(40) NOT NULL,
|
||||
`owner_user_id` INT UNSIGNED DEFAULT NULL,
|
||||
PRIMARY KEY (`uid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -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,26 +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/>.
|
||||
|
||||
-- Uid and owner of each deleted app. Mirrors SQLite migration 0094 / MySQL
|
||||
-- mysql_mig_48.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deleted_apps (
|
||||
uid VARCHAR(40) PRIMARY KEY,
|
||||
owner_user_id INTEGER DEFAULT NULL
|
||||
);
|
||||
@@ -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,25 +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/>.
|
||||
|
||||
-- Uid and owner of each deleted app. An origin-derived uid comes back when the
|
||||
-- origin is used again; on a hosted subdomain now registered to someone else,
|
||||
-- AuthService moves on to a fresh uid so the deleted app's data stays put.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `deleted_apps` (
|
||||
"uid" TEXT PRIMARY KEY NOT NULL,
|
||||
"owner_user_id" INTEGER DEFAULT NULL
|
||||
);
|
||||
@@ -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;
|
||||
}) {
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import {
|
||||
BasicTracerProvider,
|
||||
InMemorySpanExporter,
|
||||
SimpleSpanProcessor,
|
||||
} from '@opentelemetry/sdk-trace-base';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
@@ -213,122 +207,6 @@ describe('EventClient', () => {
|
||||
await target.emitAndWait(`${key}.child`, {}, {});
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
describe('tracing', () => {
|
||||
const exporter = new InMemorySpanExporter();
|
||||
const provider = new BasicTracerProvider({
|
||||
spanProcessors: [new SimpleSpanProcessor(exporter)],
|
||||
});
|
||||
const spansFor = (eventKey: string) =>
|
||||
exporter
|
||||
.getFinishedSpans()
|
||||
.filter(
|
||||
(s) =>
|
||||
s.name === 'event.emitAndWait' &&
|
||||
s.attributes['event.key'] === eventKey,
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
trace.setGlobalTracerProvider(provider);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await provider.shutdown();
|
||||
trace.disable();
|
||||
});
|
||||
|
||||
it('resolves without a span when nothing listens', async () => {
|
||||
const meta: { listener_failed?: boolean } = {};
|
||||
await expect(
|
||||
target.emitAndWait(`${key}.child`, {}, meta),
|
||||
).resolves.toBeUndefined();
|
||||
expect(meta).toEqual({});
|
||||
expect(spansFor(`${key}.child`)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('spans the wait whenever a listener matches, wildcards included', async () => {
|
||||
target.on(`${key}.*`, vi.fn());
|
||||
await expect(
|
||||
target.emitAndWait(`${key}.child`, {}, {}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(spansFor(`${key}.child`)).toHaveLength(1);
|
||||
|
||||
extensionStore.events[`${key}.ext`] = [vi.fn()];
|
||||
try {
|
||||
await target.emitAndWait(`${key}.ext`, {}, {});
|
||||
} finally {
|
||||
delete extensionStore.events[`${key}.ext`];
|
||||
}
|
||||
expect(spansFor(`${key}.ext`)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitAndWaitConcurrently', () => {
|
||||
it('runs every listener at once, wildcards included, and awaits them all', async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
const contribute =
|
||||
(share: number) => async (_k: string, data: unknown) => {
|
||||
inFlight++;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
inFlight--;
|
||||
(data as { total: number }).total += share;
|
||||
};
|
||||
target.on(`${key}.*`, contribute(1));
|
||||
target.on(`${key}.child`, contribute(10));
|
||||
target.on(`${key}.child`, contribute(100));
|
||||
|
||||
const data = { total: 0 };
|
||||
await target.emitAndWaitConcurrently(`${key}.child`, data, {});
|
||||
|
||||
expect(peak).toBe(3);
|
||||
expect(data.total).toBe(111);
|
||||
});
|
||||
|
||||
it('flags a failed exact-key listener and still awaits the rest', async () => {
|
||||
const errSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
let finished = false;
|
||||
target.on(key, () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
target.on(key, async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
finished = true;
|
||||
});
|
||||
const meta: Record<string, unknown> = {};
|
||||
await target.emitAndWaitConcurrently(key, {}, meta);
|
||||
expect(finished).toBe(true);
|
||||
expect(meta.listener_failed).toBe(true);
|
||||
expect(errSpy).toHaveBeenCalledWith(
|
||||
'Error in event listener for event',
|
||||
key,
|
||||
expect.any(Error),
|
||||
);
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('leaves the flag alone when only a wildcard listener fails', async () => {
|
||||
const errSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
target.on(`${key}.*`, () => {
|
||||
throw new Error('observer');
|
||||
});
|
||||
const meta: Record<string, unknown> = {};
|
||||
await target.emitAndWaitConcurrently(`${key}.child`, {}, meta);
|
||||
expect(meta.listener_failed).toBeUndefined();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('resolves at once with nobody listening', async () => {
|
||||
await expect(
|
||||
target.emitAndWaitConcurrently(`${key}.nobody`, {}, {}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('off', () => {
|
||||
|
||||
@@ -94,7 +94,6 @@ export class EventClient extends PuterClient {
|
||||
data: EventMap[T],
|
||||
meta: EventMetadata,
|
||||
) {
|
||||
if (!this.hasListeners(key)) return;
|
||||
// Spanned because callers block on listeners — this is where time
|
||||
// spent in extension hooks (e.g. `ip.validate`) gets attributed.
|
||||
return withSpan('event.emitAndWait', { 'event.key': key }, async () => {
|
||||
@@ -119,43 +118,6 @@ export class EventClient extends PuterClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `emitAndWait`, but every matched listener runs at once, so none sees
|
||||
* another's mutation of `data` before all have finished. Only for events
|
||||
* whose listeners each add an independent share, written after their own
|
||||
* awaits (`const v = await x; data.n += v`).
|
||||
*/
|
||||
async emitAndWaitConcurrently<T extends keyof EventMap>(
|
||||
key: T,
|
||||
data: EventMap[T],
|
||||
meta: EventMetadata,
|
||||
) {
|
||||
const matched = this.#matchKeys(key).flatMap((matchKey) =>
|
||||
this.#listenersFor(matchKey).map((listener) => ({
|
||||
listener,
|
||||
// A wildcard listener observes the event; it does not implement it.
|
||||
isExact: matchKey === key,
|
||||
})),
|
||||
);
|
||||
if (matched.length === 0) return;
|
||||
return withSpan('event.emitAndWait', { 'event.key': key }, async () => {
|
||||
await Promise.all(
|
||||
matched.map(async ({ listener, isExact }) => {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
} catch (e) {
|
||||
if (isExact) meta.listener_failed = true;
|
||||
console.error(
|
||||
'Error in event listener for event',
|
||||
key,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether anything would run if `key` were emitted, wildcards included.
|
||||
*
|
||||
|
||||
@@ -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 ----
|
||||
@@ -461,11 +459,7 @@ export type EventMap = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Bytes to add to a user's storage allowance. Listeners run concurrently:
|
||||
* add to `extra` after your own awaits, never `extra += await x`.
|
||||
*/
|
||||
'storage.quota.bonus': { userId: number; userUuid?: string; extra: number };
|
||||
'storage.quota.bonus': { userId: number; extra: number };
|
||||
|
||||
// ---- Key-value ----
|
||||
/**
|
||||
@@ -492,33 +486,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 +675,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.
|
||||
@@ -949,11 +919,10 @@ export type EventKey = keyof EventMap & string;
|
||||
// Generates a wildcard for every non-final dot-separated prefix of K.
|
||||
export type WildcardPrefixes<K extends string> =
|
||||
K extends `${infer Head}.${infer Tail}`
|
||||
?
|
||||
| `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
? | `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
: never;
|
||||
|
||||
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -338,42 +338,6 @@ describe('AppController POST /rao', () => {
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps every open as a row, and counts one per account per hour', async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const app = await createApp(owner.actor);
|
||||
|
||||
const open = async (user: { actor: Actor; userId: number }) => {
|
||||
const { res } = makeRes();
|
||||
await withActor(user.actor, () =>
|
||||
callRoute(
|
||||
'post',
|
||||
'/rao',
|
||||
makeReq({ body: { app_uid: app.uid }, actor: user.actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
for (let i = 0; i < 5; i++) await open(owner);
|
||||
await open(other);
|
||||
await server.controllers.apps.drainPendingAppOpens();
|
||||
|
||||
// Every open is still a row: the recent-apps list orders on them.
|
||||
const rows = (await server.clients.db.read(
|
||||
'SELECT `user_id` FROM `app_opens` WHERE `app_uid` = ?',
|
||||
[app.uid],
|
||||
)) as Array<{ user_id: number }>;
|
||||
expect(rows).toHaveLength(6);
|
||||
|
||||
// The count is accounts-per-hour, so repeats do not move it.
|
||||
const stats = await server.stores.app.getAppsStats([app.uid]);
|
||||
expect(stats.get(app.uid)).toMatchObject({
|
||||
open_count: 2,
|
||||
user_count: 2,
|
||||
});
|
||||
});
|
||||
|
||||
// `app_opens` is analytics: the response carries nothing derived from it
|
||||
// and the client never awaits the post. Holding the response open for a
|
||||
// primary write put that write's latency inside the app launch it was
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { TOTP } from 'otpauth';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -629,89 +680,3 @@ describe('temp-account reauth over HTTP', () => {
|
||||
expect(who.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session token helpers over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
}, 120_000);
|
||||
|
||||
const signupTemp = async () => {
|
||||
const res = await fetch(new URL('/signup', env.origin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ is_temp: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()) as { token: string; user: { uuid: string } };
|
||||
};
|
||||
|
||||
const get = (path: string, token: string) =>
|
||||
fetch(new URL(path, env.origin), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
const claims = (token: string) =>
|
||||
env.server.services.token.verify('auth', token) as {
|
||||
type: string;
|
||||
user_uid: string;
|
||||
session_uid: string;
|
||||
};
|
||||
|
||||
it('mint tokens for the session the request authenticated with', async () => {
|
||||
const { token, user } = await signupTemp();
|
||||
const session = claims(token);
|
||||
const row = await env.server.stores.user.getByUuid(user.uuid);
|
||||
const getById = vi.spyOn(env.server.stores.user, 'getById');
|
||||
try {
|
||||
const gui = await get('/get-gui-token', token);
|
||||
expect(gui.status).toBe(200);
|
||||
const { token: guiToken } = (await gui.json()) as {
|
||||
token: string;
|
||||
};
|
||||
expect(claims(guiToken)).toMatchObject({
|
||||
type: 'gui',
|
||||
user_uid: user.uuid,
|
||||
session_uid: session.session_uid,
|
||||
});
|
||||
|
||||
const sync = await get('/session/sync-cookie', token);
|
||||
expect(sync.status).toBe(204);
|
||||
const cookie = sync.headers.get('set-cookie') ?? '';
|
||||
const cookieToken = /puter_auth_token=([^;]+)/.exec(cookie)?.[1];
|
||||
expect(claims(cookieToken!)).toMatchObject({
|
||||
type: 'session',
|
||||
user_uid: user.uuid,
|
||||
session_uid: session.session_uid,
|
||||
});
|
||||
|
||||
// The user is the row token auth already read.
|
||||
expect(getById.mock.calls.filter(([id]) => id === row!.id)).toEqual(
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
getById.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuse a deleted account's session before either handler runs", async () => {
|
||||
const { token, user } = await signupTemp();
|
||||
const row = await env.server.stores.user.getByUuid(user.uuid);
|
||||
await env.server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
|
||||
row!.id,
|
||||
]);
|
||||
await env.server.stores.user.markDeleted(row!);
|
||||
|
||||
expect((await get('/get-gui-token', token)).status).toBe(401);
|
||||
const sync = await get('/session/sync-cookie', token);
|
||||
expect(sync.status).toBe(401);
|
||||
expect(sync.headers.get('set-cookie') ?? '').not.toContain(
|
||||
'puter_auth_token=',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -3886,46 +3597,6 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// The handler's own lookup is the row the grant and the token use.
|
||||
it('reads the app row once per get-user-app-token and check-app', async () => {
|
||||
const getByUid = vi.spyOn(server.stores.app, 'getByUid');
|
||||
const lookupsOf = () =>
|
||||
getByUid.mock.calls.filter(([uid]) => uid === app.uid).length;
|
||||
try {
|
||||
const tokenRes = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGetUserAppToken(
|
||||
makeReq({ app_uid: app.uid }, { actor }),
|
||||
tokenRes,
|
||||
),
|
||||
);
|
||||
expect(lookupsOf()).toBe(1);
|
||||
const minted = tokenRes.body as { token: string };
|
||||
|
||||
getByUid.mockClear();
|
||||
const checkRes = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleCheckApp(
|
||||
makeReq({ app_uid: app.uid }, { actor }),
|
||||
checkRes,
|
||||
),
|
||||
);
|
||||
expect(lookupsOf()).toBe(1);
|
||||
const checked = checkRes.body as { token: string };
|
||||
|
||||
// Both tokens name the same app session.
|
||||
const sessionOf = (token: string) =>
|
||||
(
|
||||
server.services.token.verify('auth', token) as {
|
||||
session_uid: string;
|
||||
}
|
||||
).session_uid;
|
||||
expect(sessionOf(checked.token)).toBe(sessionOf(minted.token));
|
||||
} finally {
|
||||
getByUid.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('after get-user-app-token, check-app reports authenticated:true and returns a token', async () => {
|
||||
// Ensure the flag is granted (re-run is idempotent).
|
||||
await inCtx(actor, () =>
|
||||
@@ -4440,9 +4111,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 +6274,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 +6685,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 +6710,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 +7343,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 +7557,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 +8114,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 +8143,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 +8162,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()),
|
||||
@@ -8687,7 +8308,7 @@ describe('AuthController 2FA flows', () => {
|
||||
otp_recovery_codes: 'a,b,c',
|
||||
});
|
||||
const res = makeRes();
|
||||
await controller.handleDisable2fa(makeReq({ code: liveTotp(user.username, 'TESTSECRETBASE32') }, { actor }), res);
|
||||
await controller.handleDisable2fa(makeReq({}, { actor }), res);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
const after = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
@@ -8834,9 +8455,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()),
|
||||
@@ -9775,6 +9403,56 @@ describe('AuthController.handleGetDevProfile additional branches', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController.handleGetGuiToken / handleSessionSyncCookie additional branches', () => {
|
||||
it('get-gui-token: 404 when actor has a session but the user row is gone', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const sessionRes = await server.services.auth.createSessionToken(
|
||||
user,
|
||||
{},
|
||||
);
|
||||
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
|
||||
const sessionedActor = {
|
||||
...actor,
|
||||
session: { uid: sessionUid },
|
||||
} as Actor;
|
||||
// Pull the user row out from under the session.
|
||||
await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
|
||||
user.id,
|
||||
]);
|
||||
await server.stores.user.invalidateById(user.id);
|
||||
await expect(
|
||||
controller.handleGetGuiToken(
|
||||
makeReq({}, { actor: sessionedActor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('session/sync-cookie: 404 when actor has a session but the user row is gone', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const sessionRes = await server.services.auth.createSessionToken(
|
||||
user,
|
||||
{},
|
||||
);
|
||||
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
|
||||
const sessionedActor = {
|
||||
...actor,
|
||||
session: { uid: sessionUid },
|
||||
} as Actor;
|
||||
await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
|
||||
user.id,
|
||||
]);
|
||||
await server.stores.user.invalidateById(user.id);
|
||||
|
||||
const res = makeRes();
|
||||
await controller.handleSessionSyncCookie(
|
||||
makeReq({}, { actor: sessionedActor, tokenSource: 'header' }),
|
||||
res,
|
||||
);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController.handleSendConfirmEmail additional branches', () => {
|
||||
it('throws 404 when the actor user row no longer exists', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
@@ -10585,7 +10263,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 +10330,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 +10344,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;
|
||||
}
|
||||
@@ -2144,16 +2133,10 @@ export class AuthController extends PuterController {
|
||||
const password_hash = await bcrypt.hash(password, 8);
|
||||
let result;
|
||||
try {
|
||||
result = await this.stores.user.update(
|
||||
user.id,
|
||||
{
|
||||
password: password_hash,
|
||||
pass_recovery_token: null,
|
||||
change_email_confirm_token: null,
|
||||
requires_password_change: false,
|
||||
temp_password_expires_at: null,
|
||||
},
|
||||
{ expectedRecoveryToken: decoded.token },
|
||||
result = await this.clients.db.write(
|
||||
'UPDATE `user` SET `password` = ?, `pass_recovery_token` = NULL, `change_email_confirm_token` = NULL, ' +
|
||||
'`requires_password_change` = 0, `temp_password_expires_at` = NULL WHERE `id` = ? AND `pass_recovery_token` = ?',
|
||||
[password_hash, user.id, decoded.token],
|
||||
);
|
||||
} catch (e) {
|
||||
if (!isOwnedEmailConflict(e)) throw e;
|
||||
@@ -2169,16 +2152,22 @@ export class AuthController extends PuterController {
|
||||
{ legacyCode: 'email_already_in_use' as never },
|
||||
);
|
||||
}
|
||||
if (!result) {
|
||||
const affected =
|
||||
(result as { affectedRows?: number; changes?: number })
|
||||
?.affectedRows ??
|
||||
(result as { affectedRows?: number; changes?: number })?.changes ??
|
||||
0;
|
||||
if (affected === 0) {
|
||||
throw new HttpError(400, 'Token has already been used.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
await this.stores.user.invalidateById(user.id);
|
||||
// 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.',
|
||||
@@ -2362,34 +2351,6 @@ export class AuthController extends PuterController {
|
||||
res.json({ username: new_username });
|
||||
}
|
||||
|
||||
#requireAccountTwoFactor(user: UserRow, code: unknown): void {
|
||||
if (user.otp_enabled) {
|
||||
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Enter the 6-digit code from your authenticator app.',
|
||||
{
|
||||
legacyCode: 'bad_request',
|
||||
fields: { factor: 'otp' },
|
||||
},
|
||||
);
|
||||
}
|
||||
let verified = false;
|
||||
try {
|
||||
verified =
|
||||
typeof user.otp_secret === 'string' &&
|
||||
verifyOtp(user.username, user.otp_secret, code);
|
||||
} catch {
|
||||
// Invalid or unavailable secrets cannot authorize a change.
|
||||
}
|
||||
if (!verified)
|
||||
throw new HttpError(401, 'Incorrect code.', {
|
||||
legacyCode: 'code_mismatch',
|
||||
fields: { factor: 'otp' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/user-protected/change-email', {
|
||||
userProtected: true,
|
||||
rateLimit: {
|
||||
@@ -2402,7 +2363,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.',
|
||||
@@ -2410,17 +2371,6 @@ export class AuthController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
const user =
|
||||
req.userProtected?.user ??
|
||||
(await this.stores.user.getById(req.actor!.user.id!, {
|
||||
force: true,
|
||||
}));
|
||||
if (!user)
|
||||
throw new HttpError(404, 'User not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
this.#requireAccountTwoFactor(user, req.body?.code);
|
||||
|
||||
const { new_email } = req.body ?? {};
|
||||
if (!new_email || typeof new_email !== 'string') {
|
||||
throw new HttpError(400, '`new_email` is required', {
|
||||
@@ -2462,7 +2412,6 @@ export class AuthController extends PuterController {
|
||||
token: confirm_token,
|
||||
user_id: req.actor!.user.id,
|
||||
purpose: 'change-email',
|
||||
twoFactorVerified: Boolean(user.otp_enabled),
|
||||
},
|
||||
{ expiresIn: '1h' },
|
||||
);
|
||||
@@ -2480,7 +2429,10 @@ export class AuthController extends PuterController {
|
||||
console.warn('[change-email] new-address email failed:', e);
|
||||
}
|
||||
// Notify the old address too
|
||||
if (user.email) {
|
||||
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
||||
force: true,
|
||||
});
|
||||
if (user?.email) {
|
||||
try {
|
||||
await (
|
||||
this.clients.email as unknown as {
|
||||
@@ -2537,21 +2489,19 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const user =
|
||||
typeof decoded.user_id === 'number' &&
|
||||
Number.isInteger(decoded.user_id) &&
|
||||
decoded.user_id > 0
|
||||
? await this.stores.user.getById(decoded.user_id, {
|
||||
force: true,
|
||||
})
|
||||
: null;
|
||||
if (
|
||||
!user ||
|
||||
user.change_email_confirm_token !== decoded.token ||
|
||||
typeof user.unconfirmed_change_email !== 'string' ||
|
||||
!user.unconfirmed_change_email ||
|
||||
(user.otp_enabled && decoded.twoFactorVerified !== true)
|
||||
) {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT * FROM `user` WHERE `change_email_confirm_token` = ? ORDER BY `id` ASC LIMIT 1',
|
||||
[decoded.token],
|
||||
)) as Array<Record<string, unknown>>;
|
||||
const user = rows[0] as
|
||||
| {
|
||||
id: number;
|
||||
email_confirmed?: number | boolean;
|
||||
password?: string | null;
|
||||
unconfirmed_change_email?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!user || !user.unconfirmed_change_email) {
|
||||
throw new HttpError(400, 'Invalid or expired token.', {
|
||||
legacyCode: 'token_expired' as never,
|
||||
});
|
||||
@@ -3000,45 +2950,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 +3128,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 +3140,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 +3350,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 {
|
||||
@@ -3853,7 +3750,6 @@ export class AuthController extends PuterController {
|
||||
APP_AUTHENTICATED_FLAG,
|
||||
{},
|
||||
{},
|
||||
app,
|
||||
);
|
||||
|
||||
const tokenPromise: Promise<{ token: string; expiresAt?: number }> =
|
||||
@@ -3863,7 +3759,6 @@ export class AuthController extends PuterController {
|
||||
token: await this.services.auth.getUserAppToken(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
app,
|
||||
),
|
||||
}))();
|
||||
|
||||
@@ -3966,7 +3861,6 @@ export class AuthController extends PuterController {
|
||||
result.token = await this.services.auth.getUserAppToken(
|
||||
req.actor!,
|
||||
app!.uid,
|
||||
app!,
|
||||
);
|
||||
}
|
||||
res.json(result);
|
||||
@@ -4186,7 +4080,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 +4154,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,
|
||||
@@ -4269,8 +4163,6 @@ export class AuthController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
this.#requireAccountTwoFactor(user, req.body?.code);
|
||||
|
||||
await this.stores.user.update(user.id, {
|
||||
otp_enabled: false,
|
||||
otp_recovery_codes: null,
|
||||
@@ -4348,10 +4240,13 @@ export class AuthController extends PuterController {
|
||||
throw new HttpError(400, 'No session bound to this actor', {
|
||||
legacyCode: 'session_required' as never,
|
||||
});
|
||||
// Token auth built this actor from the user's row in this request, and
|
||||
// a deleted user fails it.
|
||||
const user = await this.stores.user.getById(req.actor.user.id!);
|
||||
if (!user)
|
||||
throw new HttpError(404, 'User not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
const guiToken = this.services.auth.createGuiToken(
|
||||
req.actor.user as UserRow,
|
||||
user,
|
||||
req.actor.session.uid,
|
||||
);
|
||||
res.json({ token: guiToken });
|
||||
@@ -4381,9 +4276,13 @@ export class AuthController extends PuterController {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
// As in `/get-gui-token`: the actor is the user's row as auth read it.
|
||||
const user = await this.stores.user.getById(req.actor.user.id!);
|
||||
if (!user) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
const sessionToken = this.services.auth.createSessionTokenForSession(
|
||||
req.actor.user as UserRow,
|
||||
user,
|
||||
req.actor.session.uid,
|
||||
);
|
||||
res.cookie(this.config.cookie_name ?? 'puter_token', sessionToken, {
|
||||
@@ -4513,7 +4412,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 +4422,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 +4431,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 +4584,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 +4610,6 @@ export class AuthController extends PuterController {
|
||||
next_step: 'complete',
|
||||
token: gui_token,
|
||||
user: details,
|
||||
...(redirectUrl ? { redirectUrl } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
import { TOTP } from 'otpauth';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import { installSeatStub } from '../../testFixtures/seatStub.js';
|
||||
|
||||
describe('account change second-factor verification', () => {
|
||||
let env: PuterTestEnv;
|
||||
const password = 'email-change-password';
|
||||
const links = new Map<string, string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
vi.spyOn(env.server.clients.email, 'send').mockImplementation(
|
||||
async (to, template, values) => {
|
||||
if (template === 'email_verification_link')
|
||||
links.set(to, String(values?.link));
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const account = async (enabled = true, oidcOnly = false) => {
|
||||
const username = `email2fa_${uuidv4().slice(0, 8)}`;
|
||||
const credentials = await createTestUser(env.server, {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
const user = (await env.server.stores.user.getByUsername(username))!;
|
||||
const otp = new TOTP({
|
||||
issuer: 'puter.com',
|
||||
label: username,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
});
|
||||
await env.server.stores.user.update(user.id, {
|
||||
email: `${username}@example.com`,
|
||||
email_confirmed: true,
|
||||
otp_enabled: enabled,
|
||||
otp_secret: otp.secret.base32,
|
||||
...(oidcOnly ? { password: null } : {}),
|
||||
});
|
||||
return { ...credentials, id: user.id, uuid: user.uuid, otp };
|
||||
};
|
||||
|
||||
const change = (
|
||||
token: string,
|
||||
body: Record<string, unknown>,
|
||||
revalidation?: string,
|
||||
path = '/user-protected/change-email',
|
||||
) =>
|
||||
fetch(new URL(path, env.origin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
cookie: `puter_auth_token=${token}${revalidation ? `; puter_revalidation=${revalidation}` : ''}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
it.each([undefined, 'invalid-code', 123456, {}])(
|
||||
'rejects an enabled account with code %j before staging a change',
|
||||
async (code) => {
|
||||
const user = await account();
|
||||
const response = await change(user.token, {
|
||||
password,
|
||||
new_email: '[email protected]',
|
||||
code,
|
||||
});
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
expect(response.status).toBeLessThan(500);
|
||||
expect(await response.json()).toMatchObject({ factor: 'otp' });
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.change_email_confirm_token).toBeFalsy();
|
||||
expect(after!.unconfirmed_change_email).toBeFalsy();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an incorrect six-digit code without staging the address', async () => {
|
||||
const user = await account();
|
||||
const code = ['000000', '000001', '000002', '000003'].find(
|
||||
(token) => user.otp.validate({ token, window: 1 }) === null,
|
||||
);
|
||||
const response = await change(user.token, {
|
||||
password,
|
||||
new_email: '[email protected]',
|
||||
code,
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toMatchObject({
|
||||
code: 'code_mismatch',
|
||||
factor: 'otp',
|
||||
});
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, { force: true }))!
|
||||
.change_email_confirm_token,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('refuses a change when the enabled account secret is unavailable', async () => {
|
||||
const user = await account();
|
||||
await env.server.stores.user.update(user.id, {
|
||||
otp_secret: 'invalid!',
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await change(user.token, {
|
||||
password,
|
||||
new_email: '[email protected]',
|
||||
code: '000000',
|
||||
})
|
||||
).status,
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it('requires a code even after OIDC revalidation', async () => {
|
||||
const user = await account(true, true);
|
||||
const proof = env.server.services.token.sign(
|
||||
'oidc-state',
|
||||
{
|
||||
user_uuid: user.uuid,
|
||||
purpose: 'revalidate',
|
||||
},
|
||||
{ expiresIn: '5m' },
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await change(
|
||||
user.token,
|
||||
{ new_email: '[email protected]' },
|
||||
proof,
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await change(
|
||||
user.token,
|
||||
{
|
||||
new_email: '[email protected]',
|
||||
code: user.otp.generate(),
|
||||
},
|
||||
proof,
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'completes the change with the required factors (2FA enabled: %s)',
|
||||
async (enabled) => {
|
||||
const user = await account(enabled);
|
||||
const newEmail = `${uuidv4()}@example.com`;
|
||||
expect(
|
||||
(
|
||||
await change(user.token, {
|
||||
password,
|
||||
new_email: newEmail,
|
||||
...(enabled ? { code: user.otp.generate() } : {}),
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
const link = links.get(newEmail)!;
|
||||
expect(link).toBeTruthy();
|
||||
expect((await fetch(link, { redirect: 'manual' })).status).toBe(
|
||||
200,
|
||||
);
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
}))!.email,
|
||||
).toBe(newEmail);
|
||||
},
|
||||
);
|
||||
|
||||
it('requires a new request if 2FA is enabled after the link was issued', async () => {
|
||||
const user = await account(false);
|
||||
const newEmail = `${uuidv4()}@example.com`;
|
||||
expect(
|
||||
(await change(user.token, { password, new_email: newEmail }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
await env.server.stores.user.update(user.id, { otp_enabled: true });
|
||||
expect(
|
||||
(await fetch(links.get(newEmail)!, { redirect: 'manual' })).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, { force: true }))!
|
||||
.email,
|
||||
).not.toBe(newEmail);
|
||||
});
|
||||
|
||||
it('refuses an existing confirmation link that never verified 2FA', async () => {
|
||||
const user = await account();
|
||||
const token = uuidv4();
|
||||
await env.server.stores.user.update(user.id, {
|
||||
change_email_confirm_token: token,
|
||||
unconfirmed_change_email: '[email protected]',
|
||||
});
|
||||
const link = env.server.services.token.sign(
|
||||
'otp',
|
||||
{
|
||||
token,
|
||||
user_id: user.id,
|
||||
purpose: 'change-email',
|
||||
},
|
||||
{ expiresIn: '1h' },
|
||||
);
|
||||
const response = await fetch(
|
||||
new URL(`/change_email/confirm?token=${link}`, env.origin),
|
||||
{ redirect: 'manual' },
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, { force: true }))!
|
||||
.email,
|
||||
).toBe(`${user.username}@example.com`);
|
||||
});
|
||||
describe('disabling 2FA', () => {
|
||||
it.each([undefined, 'invalid-code', 123456, {}])(
|
||||
'refuses code %j and retains the second-factor configuration',
|
||||
async (code) => {
|
||||
const user = await account();
|
||||
await env.server.stores.user.update(user.id, {
|
||||
otp_recovery_codes: 'one,two,three',
|
||||
});
|
||||
const response = await change(
|
||||
user.token,
|
||||
{ password, code },
|
||||
undefined,
|
||||
'/user-protected/disable-2fa',
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({ factor: 'otp' });
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.otp_enabled).toBeTruthy();
|
||||
expect(after!.otp_secret).toBe(user.otp.secret.base32);
|
||||
expect(after!.otp_recovery_codes).toBe('one,two,three');
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an incorrect six-digit code', async () => {
|
||||
const user = await account();
|
||||
const code = ['000000', '000001', '000002', '000003'].find(
|
||||
(token) => user.otp.validate({ token, window: 1 }) === null,
|
||||
);
|
||||
const response = await change(
|
||||
user.token,
|
||||
{ password, code },
|
||||
undefined,
|
||||
'/user-protected/disable-2fa',
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toMatchObject({
|
||||
code: 'code_mismatch',
|
||||
factor: 'otp',
|
||||
});
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
}))!.otp_enabled,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'accepts a current code after proving identity (OIDC-only: %s)',
|
||||
async (oidcOnly) => {
|
||||
const user = await account(true, oidcOnly);
|
||||
const proof = oidcOnly
|
||||
? env.server.services.token.sign(
|
||||
'oidc-state',
|
||||
{
|
||||
user_uuid: user.uuid,
|
||||
purpose: 'revalidate',
|
||||
},
|
||||
{ expiresIn: '5m' },
|
||||
)
|
||||
: undefined;
|
||||
const missing = await change(
|
||||
user.token,
|
||||
oidcOnly ? {} : { password },
|
||||
proof,
|
||||
'/user-protected/disable-2fa',
|
||||
);
|
||||
expect(missing.status).toBe(400);
|
||||
const response = await change(
|
||||
user.token,
|
||||
{
|
||||
...(oidcOnly ? {} : { password }),
|
||||
code: user.otp.generate(),
|
||||
},
|
||||
proof,
|
||||
'/user-protected/disable-2fa',
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.otp_enabled).toBeFalsy();
|
||||
expect(after!.otp_secret).toBeNull();
|
||||
expect(after!.otp_recovery_codes).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not accept a code without password or OIDC revalidation', async () => {
|
||||
const user = await account();
|
||||
const response = await change(
|
||||
user.token,
|
||||
{ code: user.otp.generate() },
|
||||
undefined,
|
||||
'/user-protected/disable-2fa',
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toMatchObject({
|
||||
code: 'password_required',
|
||||
});
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
}))!.otp_enabled,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not let a valid code override a team requirement for 2FA', async () => {
|
||||
const member = await account(true, true);
|
||||
installSeatStub(env.server as never, [member.id], {
|
||||
require2fa: true,
|
||||
});
|
||||
const proof = env.server.services.token.sign('oidc-state', {
|
||||
user_uuid: member.uuid, purpose: 'revalidate',
|
||||
}, { expiresIn: '5m' });
|
||||
const response = await change(
|
||||
member.token,
|
||||
{ code: member.otp.generate() },
|
||||
proof,
|
||||
'/user-protected/disable-2fa',
|
||||
);
|
||||
expect(response.status).toBe(409);
|
||||
expect(
|
||||
(await env.server.stores.user.getById(member.id, {
|
||||
force: true,
|
||||
}))!.otp_enabled,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('remains idempotent for an account with 2FA already disabled', async () => {
|
||||
const user = await account(false);
|
||||
expect(
|
||||
(
|
||||
await change(
|
||||
user.token,
|
||||
{ password },
|
||||
undefined,
|
||||
'/user-protected/disable-2fa',
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -823,10 +823,9 @@ describe('DriverController.#handleCall tracing', () => {
|
||||
'driver.method': 'set',
|
||||
'driver.name': 'puter-kvstore',
|
||||
});
|
||||
// The KV store's dynamo writes produce spans of their own. The
|
||||
// permission gate doesn't: every user holds `service` by default, which
|
||||
// `check` answers without a scan.
|
||||
expect(names).not.toContain('permission.scan');
|
||||
// The call path exercises the permission gate and the KV store's
|
||||
// dynamo writes — both should have produced spans of their own.
|
||||
expect(names).toContain('permission.scan');
|
||||
expect(names.some((n) => n.startsWith('ddb.'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'node:stream';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
/**
|
||||
@@ -551,111 +549,3 @@ describe('POST /fs/startWrite storage quota over HTTP', () => {
|
||||
expect(responseBody.code).toBe('bad_request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file reads over HTTP when storage fails mid-download', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const writeFile = async (name: string, content: string) => {
|
||||
const { username } = env.users.user;
|
||||
const user = await env.server.stores.user.getByUsername(username);
|
||||
const body = Buffer.from(content);
|
||||
const { fsEntry } = await env.server.services.fs.write(user!.id, {
|
||||
fileMetadata: {
|
||||
path: `/${username}/Documents/${name}`,
|
||||
size: body.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: body,
|
||||
});
|
||||
return fsEntry;
|
||||
};
|
||||
|
||||
// Storage hands over one chunk and then fails, the way a dropped
|
||||
// connection or a checksum mismatch surfaces on a read in flight.
|
||||
const breakReadsOf = (objectKey: string) => {
|
||||
const s3 = env.server.stores.s3Object;
|
||||
const client = env.server.clients.s3.get(s3.resolveRegion(null));
|
||||
const send = client.send.bind(client);
|
||||
return vi.spyOn(client, 'send').mockImplementation(((
|
||||
command: unknown,
|
||||
) => {
|
||||
if (
|
||||
!(command instanceof GetObjectCommand) ||
|
||||
command.input.Key !== objectKey
|
||||
) {
|
||||
return send(command as never);
|
||||
}
|
||||
let reads = 0;
|
||||
const body = new Readable({
|
||||
read() {
|
||||
reads += 1;
|
||||
if (reads === 1) this.push('partial');
|
||||
// Late enough for the headers and first chunk to go out.
|
||||
else if (reads === 2)
|
||||
setTimeout(
|
||||
() => this.destroy(new Error('Checksum mismatch')),
|
||||
25,
|
||||
);
|
||||
},
|
||||
});
|
||||
return Promise.resolve({
|
||||
Body: body,
|
||||
ContentLength: 64,
|
||||
ContentType: 'text/plain',
|
||||
});
|
||||
}) as never);
|
||||
};
|
||||
|
||||
it.each([
|
||||
['/fs/read', 'path'],
|
||||
['/read', 'file'],
|
||||
])(
|
||||
'GET %s fails that response and keeps serving others',
|
||||
async (route, param) => {
|
||||
const { username, token } = env.users.user;
|
||||
const urlFor = (name: string) => {
|
||||
const url = new URL(route, env.apiOrigin);
|
||||
url.searchParams.set(param, `/${username}/Documents/${name}`);
|
||||
return url;
|
||||
};
|
||||
const suffix = route.replace(/\W/g, '');
|
||||
const broken = await writeFile(
|
||||
`broken-${suffix}.txt`,
|
||||
'x'.repeat(64),
|
||||
);
|
||||
await writeFile(`healthy-${suffix}.txt`, 'still fine');
|
||||
|
||||
const send = breakReadsOf(broken.uuid);
|
||||
let status = 0;
|
||||
let completed = false;
|
||||
try {
|
||||
const response = await fetch(urlFor(`broken-${suffix}.txt`), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
status = response.status;
|
||||
await response.arrayBuffer();
|
||||
completed = true;
|
||||
} catch {
|
||||
// Expected: the transfer is cut short.
|
||||
} finally {
|
||||
send.mockRestore();
|
||||
}
|
||||
expect(status).toBe(200);
|
||||
expect(completed).toBe(false);
|
||||
|
||||
const healthy = await fetch(urlFor(`healthy-${suffix}.txt`), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(healthy.status).toBe(200);
|
||||
expect(await healthy.text()).toBe('still fine');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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!;
|
||||
|
||||
@@ -20,15 +20,12 @@
|
||||
import Busboy from 'busboy';
|
||||
import type { Request, Response } from 'express';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
import { pipeToResponse } from '../../core/http/pipeToResponse.js';
|
||||
import {
|
||||
FS_ROUTE_REFUSAL,
|
||||
type ResourceDescriptor,
|
||||
} from '../../services/acl/ACLService.js';
|
||||
import { FS_ROUTE_REFUSAL } from '../../services/acl/ACLService.js';
|
||||
import { clientParentUid } from '../../services/fs/rootListing.js';
|
||||
import {
|
||||
expandTildePath,
|
||||
@@ -45,11 +42,7 @@ import type {
|
||||
UploadedBatchWriteItem,
|
||||
UploadProgressTrackerLike,
|
||||
} from '../../services/fs/types.js';
|
||||
import type {
|
||||
FSEntry,
|
||||
FSEntryWriteInput,
|
||||
PendingUploadSession,
|
||||
} from '../../stores/fs/FSEntry.js';
|
||||
import type { FSEntry, FSEntryWriteInput } from '../../stores/fs/FSEntry.js';
|
||||
import {
|
||||
runWithConcurrencyLimit,
|
||||
runWithConcurrencyLimitSettled,
|
||||
@@ -82,8 +75,6 @@ import {
|
||||
type GuiItemEventName,
|
||||
loadLegacyAssociatedApps,
|
||||
replacedByMove,
|
||||
resolveOrNotFound,
|
||||
resolveReadRange,
|
||||
signEntryThumbnail,
|
||||
} from './legacyFsHelpers.js';
|
||||
import type {
|
||||
@@ -159,14 +150,8 @@ export class FSController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
// Write routes admit scoped access tokens for the same reason the reads
|
||||
// do (see the note above `/stat`): every handler authorizes the target
|
||||
// through the ACL, which intersects a token's grant with its issuer's, so
|
||||
// a token writes exactly where it was granted write — the parity WebDAV
|
||||
// already has. Upload sessions are re-checked on complete, sign and abort.
|
||||
@Post('/startWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -233,7 +218,6 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/startBatchWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -340,7 +324,6 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/completeWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -383,7 +366,6 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/completeBatchWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -414,26 +396,26 @@ export class FSController extends PuterController {
|
||||
userId,
|
||||
requests,
|
||||
);
|
||||
const withSideEffects = await this.#applyWriteResponsesSideEffects(
|
||||
userId,
|
||||
response.map((writeResponse, index) => {
|
||||
const write: WriteResponse = {
|
||||
fsEntry: writeResponse.fsEntry,
|
||||
wasOverwrite: writeResponse.wasOverwrite,
|
||||
requestedThumbnail: writeResponse.requestedThumbnail,
|
||||
contentHashSha256: null,
|
||||
};
|
||||
return {
|
||||
response: write,
|
||||
guiMetadata: requests[index]?.guiMetadata,
|
||||
};
|
||||
}),
|
||||
const updatedResponse = await runWithConcurrencyLimit(
|
||||
response,
|
||||
DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY,
|
||||
async (writeResponse, index) => {
|
||||
const requestBody = requests[index];
|
||||
const withSideEffects =
|
||||
await this.#applyWriteResponseSideEffects(
|
||||
userId,
|
||||
{
|
||||
fsEntry: writeResponse.fsEntry,
|
||||
wasOverwrite: writeResponse.wasOverwrite,
|
||||
requestedThumbnail:
|
||||
writeResponse.requestedThumbnail,
|
||||
contentHashSha256: null,
|
||||
},
|
||||
requestBody?.guiMetadata,
|
||||
);
|
||||
return { ...writeResponse, fsEntry: withSideEffects.fsEntry };
|
||||
},
|
||||
);
|
||||
const updatedResponse = response.map((writeResponse, index) => ({
|
||||
...writeResponse,
|
||||
fsEntry: withSideEffects[index]?.fsEntry ?? writeResponse.fsEntry,
|
||||
}));
|
||||
res.json(
|
||||
updatedResponse.map((r) => this.#withRequiredClientFsEntry(r)),
|
||||
);
|
||||
@@ -441,7 +423,6 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/abortWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
})
|
||||
@@ -456,31 +437,12 @@ export class FSController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
// An unknown id stays a no-op, as it always was.
|
||||
try {
|
||||
await this.services.fs.getUploadSessions(userId, [
|
||||
req.body.uploadId,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError && error.statusCode === 404)) {
|
||||
throw error;
|
||||
}
|
||||
res.json({ ok: true });
|
||||
return;
|
||||
}
|
||||
// Same re-check as complete/sign: a scoped token may only abort an
|
||||
// upload whose target it could write.
|
||||
await this.#assertUploadSessionWriteAccess(req, userId, [
|
||||
req.body.uploadId,
|
||||
]);
|
||||
|
||||
await this.services.fs.abortUrlWrite(userId, req.body.uploadId);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
@Post('/signMultipartParts', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -501,22 +463,18 @@ export class FSController extends PuterController {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const sessions = await this.#assertUploadSessionWriteAccess(
|
||||
req,
|
||||
userId,
|
||||
[req.body?.uploadId],
|
||||
);
|
||||
await this.#assertUploadSessionWriteAccess(req, userId, [
|
||||
req.body?.uploadId,
|
||||
]);
|
||||
const response = await this.services.fs.signMultipartParts(
|
||||
userId,
|
||||
req.body,
|
||||
sessions?.[0],
|
||||
);
|
||||
res.json(this.#withoutStorageInternals(response));
|
||||
}
|
||||
|
||||
@Post('/write', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_WRITE_LIMIT,
|
||||
@@ -572,7 +530,6 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/batchWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_WRITE_LIMIT,
|
||||
@@ -908,13 +865,17 @@ export class FSController extends PuterController {
|
||||
preparedBatch,
|
||||
uploadedItems,
|
||||
);
|
||||
const updatedResponses = await this.#applyWriteResponsesSideEffects(
|
||||
userId,
|
||||
writeResponses.map((writeResponse, index) => ({
|
||||
response: writeResponse,
|
||||
guiMetadata: preparedBatch?.items[index]?.guiMetadata,
|
||||
})),
|
||||
const updatedResponses = await runWithConcurrencyLimit(
|
||||
writeResponses,
|
||||
32,
|
||||
async (writeResponse, index) => {
|
||||
const preparedItem = preparedBatch?.items[index];
|
||||
return this.#applyWriteResponseSideEffects(
|
||||
userId,
|
||||
writeResponse,
|
||||
preparedItem?.guiMetadata,
|
||||
);
|
||||
},
|
||||
);
|
||||
res.json(
|
||||
updatedResponses.map((r) => this.#withRequiredClientFsEntry(r)),
|
||||
@@ -1027,13 +988,17 @@ export class FSController extends PuterController {
|
||||
preparedBatch,
|
||||
uploadedItems,
|
||||
);
|
||||
const updatedResponses = await this.#applyWriteResponsesSideEffects(
|
||||
userId,
|
||||
writeResponses.map((writeResponse, index) => ({
|
||||
response: writeResponse,
|
||||
guiMetadata: filteredRequests[index]?.guiMetadata,
|
||||
})),
|
||||
const updatedResponses = await runWithConcurrencyLimit(
|
||||
writeResponses,
|
||||
32,
|
||||
async (writeResponse, index) => {
|
||||
const requestBody = filteredRequests[index];
|
||||
return this.#applyWriteResponseSideEffects(
|
||||
userId,
|
||||
writeResponse,
|
||||
requestBody?.guiMetadata,
|
||||
);
|
||||
},
|
||||
);
|
||||
res.json(
|
||||
updatedResponses.map((r) => this.#withRequiredClientFsEntry(r)),
|
||||
@@ -1056,9 +1021,7 @@ export class FSController extends PuterController {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = this.#toObjectRecord(req.body);
|
||||
const entry = await this.#resolveEntryForRequest(body);
|
||||
// One descriptor for both checks, so the chain is read at most once.
|
||||
const descriptor = this.services.acl.fsDescriptor(entry.path);
|
||||
await this.#assertAccess(actor, descriptor, 'see');
|
||||
await this.#assertAccess(actor, entry.path, 'see');
|
||||
|
||||
const wantsSize = this.#toBoolean(body.return_size);
|
||||
const wantsShares = this.#toBoolean(body.return_shares);
|
||||
@@ -1076,7 +1039,7 @@ export class FSController extends PuterController {
|
||||
const mayReadSize =
|
||||
entry.isDir &&
|
||||
wantsSize &&
|
||||
(await this.#canAccess(actor, descriptor, 'list'));
|
||||
(await this.#canAccess(actor, entry.path, 'list'));
|
||||
|
||||
const [subtreeSize, suggestedApps, shareFlags, shares, parentUid] =
|
||||
await Promise.all([
|
||||
@@ -1496,7 +1459,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,
|
||||
});
|
||||
@@ -1522,14 +1488,19 @@ export class FSController extends PuterController {
|
||||
);
|
||||
res.status(range ? 206 : 200);
|
||||
|
||||
await pipeToResponse(download.body, res);
|
||||
try {
|
||||
await pipeline(download.body, res);
|
||||
} catch {
|
||||
// Client disconnect or upstream stream error — pipeline already
|
||||
// tore down both ends. Response is partially sent; nothing to do.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Mutation routes ------------------------------------------------
|
||||
|
||||
@Post('/mkdir', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1575,7 +1546,6 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/touch', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1616,7 +1586,6 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/rename', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1640,7 +1609,6 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/delete', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1667,7 +1635,6 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/move', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1721,7 +1688,6 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/copy', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
@@ -1773,7 +1739,6 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/mkshortcut', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1825,6 +1790,7 @@ export class FSController extends PuterController {
|
||||
}
|
||||
|
||||
async #resolveEntryForRequest(source: Record<string, unknown>) {
|
||||
const mod = await import('../../services/fs/resolveNode.js');
|
||||
const username = Context.get('actor')?.user?.username;
|
||||
const rawPath =
|
||||
typeof source.path === 'string' ? source.path : undefined;
|
||||
@@ -1844,8 +1810,15 @@ export class FSController extends PuterController {
|
||||
? source.id
|
||||
: undefined,
|
||||
};
|
||||
// The resolved path is the owner's real one, so a miss must not echo it.
|
||||
return resolveOrNotFound(this.stores.fsEntry, ref);
|
||||
const entry = await mod.resolveNode(this.stores.fsEntry, ref, {
|
||||
required: true,
|
||||
});
|
||||
if (!entry) {
|
||||
throw new HttpError(404, 'Entry not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
async #assertCanDedupeCreate(actor: Actor, targetPath: string) {
|
||||
@@ -1862,20 +1835,16 @@ export class FSController extends PuterController {
|
||||
/** As `#assertAccess`, but for what to include rather than what to serve. */
|
||||
async #canAccess(
|
||||
actor: Actor,
|
||||
path: string | ResourceDescriptor,
|
||||
path: string,
|
||||
mode: 'see' | 'list' | 'read' | 'write',
|
||||
): Promise<boolean> {
|
||||
const acl = this.services.acl;
|
||||
return acl.check(
|
||||
actor,
|
||||
typeof path === 'string' ? acl.fsDescriptor(path) : path,
|
||||
mode,
|
||||
);
|
||||
return acl.check(actor, acl.fsDescriptor(path), mode);
|
||||
}
|
||||
|
||||
async #assertAccess(
|
||||
actor: Actor,
|
||||
path: string | ResourceDescriptor,
|
||||
path: string,
|
||||
mode: 'see' | 'list' | 'read' | 'write',
|
||||
) {
|
||||
await this.services.acl.assertFsAccess(
|
||||
@@ -2271,8 +2240,7 @@ export class FSController extends PuterController {
|
||||
// the ActorUser type. Access via the escape hatch until a proper
|
||||
// storage-quota mechanism is in place.
|
||||
const actorUser = req.actor?.user as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
|
||||
const candidates = [
|
||||
this.#toStorageCapacityCandidate(actorUser?.free_storage),
|
||||
@@ -2510,24 +2478,19 @@ export class FSController extends PuterController {
|
||||
* `startWrite` checked has to be re-checked against the session's recorded
|
||||
* target every time the caller signs more parts or completes the upload —
|
||||
* otherwise a revoked sharee still lands bytes in the owner's tree.
|
||||
*
|
||||
* Returns the sessions it checked, in request order, for signing to reuse;
|
||||
* undefined when any id was left to the service to read and reject.
|
||||
* Completion reads its own, so a session settled during this check still
|
||||
* gets a 409.
|
||||
*/
|
||||
async #assertUploadSessionWriteAccess(
|
||||
req: Request,
|
||||
userId: number,
|
||||
uploadIds: Array<string | undefined>,
|
||||
): Promise<PendingUploadSession[] | undefined> {
|
||||
): Promise<void> {
|
||||
// A malformed id is left to the service, which owns that error shape.
|
||||
const knownUploadIds = uploadIds.filter(
|
||||
(uploadId): uploadId is string =>
|
||||
typeof uploadId === 'string' && uploadId.length > 0,
|
||||
);
|
||||
if (knownUploadIds.length === 0) {
|
||||
return undefined;
|
||||
return;
|
||||
}
|
||||
const sessions = await this.services.fs.getUploadSessions(
|
||||
userId,
|
||||
@@ -2542,9 +2505,6 @@ export class FSController extends PuterController {
|
||||
})),
|
||||
{ pathAlreadyNormalized: true },
|
||||
);
|
||||
return knownUploadIds.length === uploadIds.length
|
||||
? sessions
|
||||
: undefined;
|
||||
}
|
||||
|
||||
#toEventGuiMetadata(
|
||||
@@ -2659,16 +2619,16 @@ export class FSController extends PuterController {
|
||||
return this.#estimateDataUrlSize(thumbnail) > MAX_THUMBNAIL_BYTES;
|
||||
}
|
||||
|
||||
/** The thumbnail a write should store on `fsEntry`, or null for none. */
|
||||
async #thumbnailToStore(
|
||||
async #applyThumbnailAfterWrite(
|
||||
userId: number,
|
||||
fsEntry: FSEntry,
|
||||
requestedThumbnail: string | null | undefined,
|
||||
): Promise<string | null> {
|
||||
): Promise<FSEntry> {
|
||||
if (!requestedThumbnail || this.#isAppDataPath(fsEntry.path)) {
|
||||
return null;
|
||||
return fsEntry;
|
||||
}
|
||||
if (this.#isOversizedThumbnailDataUrl(requestedThumbnail)) {
|
||||
return null;
|
||||
return fsEntry;
|
||||
}
|
||||
|
||||
const thumbnailPayload = {
|
||||
@@ -2689,10 +2649,15 @@ export class FSController extends PuterController {
|
||||
? thumbnailPayload.url
|
||||
: null;
|
||||
|
||||
if (finalThumbnail === fsEntry.thumbnail) {
|
||||
return null;
|
||||
if (finalThumbnail === fsEntry.thumbnail || finalThumbnail === null) {
|
||||
return fsEntry;
|
||||
}
|
||||
return finalThumbnail;
|
||||
|
||||
return this.services.fs.updateEntryThumbnail(
|
||||
userId,
|
||||
fsEntry.uuid,
|
||||
finalThumbnail,
|
||||
);
|
||||
}
|
||||
|
||||
#toThumbnailPrepareItem(
|
||||
@@ -2922,130 +2887,36 @@ export class FSController extends PuterController {
|
||||
response: WriteResponse,
|
||||
guiMetadata: WriteGuiMetadata | undefined,
|
||||
): Promise<WriteResponse> {
|
||||
const [result] = await this.#applyWriteResponsesSideEffects(
|
||||
userId,
|
||||
[{ response, guiMetadata }],
|
||||
1,
|
||||
);
|
||||
return result ?? response;
|
||||
}
|
||||
let fsEntry = response.fsEntry;
|
||||
|
||||
/**
|
||||
* Each write's hash event, thumbnail and GUI event. Thumbnails are vetted
|
||||
* `concurrency` writes at a time, then the caller's own go to the store in
|
||||
* one update; a write into someone else's folder is updated alone.
|
||||
*/
|
||||
async #applyWriteResponsesSideEffects(
|
||||
userId: number,
|
||||
writes: Array<{
|
||||
response: WriteResponse;
|
||||
guiMetadata: WriteGuiMetadata | undefined;
|
||||
}>,
|
||||
concurrency: number,
|
||||
): Promise<WriteResponse[]> {
|
||||
type Write = (typeof writes)[number];
|
||||
const results: WriteResponse[] = new Array(writes.length);
|
||||
const finish = async (
|
||||
index: number,
|
||||
{ response, guiMetadata }: Write,
|
||||
fsEntry: FSEntry,
|
||||
hashEventPromise: Promise<void>,
|
||||
) => {
|
||||
await this.#runNonCritical(async () => {
|
||||
await this.#emitGuiWriteEvent(
|
||||
response.wasOverwrite
|
||||
? 'outer.gui.item.updated'
|
||||
: 'outer.gui.item.added',
|
||||
fsEntry,
|
||||
guiMetadata,
|
||||
);
|
||||
}, 'emitGuiWriteEvent');
|
||||
await hashEventPromise;
|
||||
results[index] = { ...response, fsEntry };
|
||||
};
|
||||
const hashEventPromise = this.#runNonCritical(async () => {
|
||||
await this.#emitWriteHashEvent(
|
||||
response.contentHashSha256,
|
||||
fsEntry.uuid,
|
||||
);
|
||||
}, 'emitWriteHashEvent');
|
||||
|
||||
const toStore: Array<{
|
||||
index: number;
|
||||
write: Write;
|
||||
thumbnail: string;
|
||||
hashEventPromise: Promise<void>;
|
||||
}> = [];
|
||||
await runWithConcurrencyLimit(
|
||||
writes,
|
||||
concurrency,
|
||||
async (write, index) => {
|
||||
const { fsEntry, contentHashSha256, requestedThumbnail } =
|
||||
write.response;
|
||||
const hashEventPromise = this.#runNonCritical(async () => {
|
||||
await this.#emitWriteHashEvent(
|
||||
contentHashSha256,
|
||||
fsEntry.uuid,
|
||||
);
|
||||
}, 'emitWriteHashEvent');
|
||||
let thumbnail = null as string | null;
|
||||
await this.#runNonCritical(async () => {
|
||||
thumbnail = await this.#thumbnailToStore(
|
||||
fsEntry,
|
||||
requestedThumbnail,
|
||||
);
|
||||
}, 'applyThumbnailAfterWrite');
|
||||
if (thumbnail === null) {
|
||||
await finish(index, write, fsEntry, hashEventPromise);
|
||||
return;
|
||||
}
|
||||
toStore.push({ index, write, thumbnail, hashEventPromise });
|
||||
},
|
||||
);
|
||||
if (toStore.length === 0) return results;
|
||||
await this.#runNonCritical(async () => {
|
||||
fsEntry = await this.#applyThumbnailAfterWrite(
|
||||
userId,
|
||||
response.fsEntry,
|
||||
response.requestedThumbnail,
|
||||
);
|
||||
}, 'applyThumbnailAfterWrite');
|
||||
|
||||
// The update is scoped to the caller's rows, so batching anyone else's
|
||||
// would only fail the batch.
|
||||
const own = toStore.filter(
|
||||
({ write }) => write.response.fsEntry.userId === userId,
|
||||
);
|
||||
const groups = [
|
||||
own,
|
||||
...toStore
|
||||
.filter(({ write }) => write.response.fsEntry.userId !== userId)
|
||||
.map((item) => [item]),
|
||||
].filter((group) => group.length > 0);
|
||||
const stored = new Map<number, PromiseSettledResult<FSEntry>>();
|
||||
await runWithConcurrencyLimit(groups, concurrency, async (group) => {
|
||||
let outcomes: PromiseSettledResult<FSEntry>[];
|
||||
try {
|
||||
outcomes = await this.services.fs.updateEntryThumbnails(
|
||||
userId,
|
||||
group.map(({ write, thumbnail }) => ({
|
||||
uuid: write.response.fsEntry.uuid,
|
||||
thumbnail,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
outcomes = group.map(() => ({
|
||||
status: 'rejected',
|
||||
reason: error,
|
||||
}));
|
||||
}
|
||||
group.forEach(({ index }, i) => {
|
||||
const outcome = outcomes[i];
|
||||
if (outcome) stored.set(index, outcome);
|
||||
});
|
||||
});
|
||||
await this.#runNonCritical(async () => {
|
||||
await this.#emitGuiWriteEvent(
|
||||
response.wasOverwrite
|
||||
? 'outer.gui.item.updated'
|
||||
: 'outer.gui.item.added',
|
||||
fsEntry,
|
||||
guiMetadata,
|
||||
);
|
||||
}, 'emitGuiWriteEvent');
|
||||
|
||||
await runWithConcurrencyLimit(
|
||||
toStore,
|
||||
concurrency,
|
||||
async ({ index, write, hashEventPromise }) => {
|
||||
let fsEntry = write.response.fsEntry;
|
||||
await this.#runNonCritical(async () => {
|
||||
const outcome = stored.get(index);
|
||||
if (outcome?.status === 'rejected') throw outcome.reason;
|
||||
if (outcome) fsEntry = outcome.value;
|
||||
}, 'applyThumbnailAfterWrite');
|
||||
await finish(index, write, fsEntry, hashEventPromise);
|
||||
},
|
||||
);
|
||||
return results;
|
||||
await hashEventPromise;
|
||||
|
||||
return { ...response, fsEntry };
|
||||
}
|
||||
|
||||
#shouldIgnoreUploadPath(targetPath: string): boolean {
|
||||
|
||||
@@ -26,7 +26,6 @@ import type { Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { toPendingUploadSessionKey } from '../../stores/fs/pendingUploadSessionHelpers.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import type { FSController } from './FSController.js';
|
||||
import { FS_BATCH_WRITE_MAX_ITEMS, FS_MULTIPART_MAX_PARTS } from './limits.js';
|
||||
@@ -1257,271 +1256,8 @@ describe('FSController.completeWrite', () => {
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
// One read for the access check and one for the status decision; the
|
||||
// completion is recorded from the second without reading it back.
|
||||
it('reads the session twice and records the completion from the second copy', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const target = `/${username}/Documents/complete-once.txt`;
|
||||
const started = await startSignedWrite(actor, target, 4);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'wxyz' });
|
||||
const before = (await server.stores.fsEntry.getPendingEntryBySessionId(
|
||||
started.sessionId,
|
||||
))!;
|
||||
|
||||
const reads = countSessionReads();
|
||||
try {
|
||||
await withActor(actor, () =>
|
||||
controller.completeWrite(
|
||||
makeReq<CompleteWriteRequest>({
|
||||
body: { uploadId: started.sessionId },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
expect(reads.of(started.sessionId)).toBe(2);
|
||||
} finally {
|
||||
reads.restore();
|
||||
}
|
||||
|
||||
const after = (await server.stores.fsEntry.getPendingEntryBySessionId(
|
||||
started.sessionId,
|
||||
))!;
|
||||
expect(after).toEqual({
|
||||
...before,
|
||||
status: 'completed',
|
||||
failureReason: null,
|
||||
updatedAt: after.completedAt,
|
||||
consumedAt: after.completedAt,
|
||||
completedAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('reads each session of a batch completion twice', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const started = [];
|
||||
for (const name of ['batch-once-a.txt', 'batch-once-b.txt']) {
|
||||
const session = await startSignedWrite(
|
||||
actor,
|
||||
`/${username}/Documents/${name}`,
|
||||
2,
|
||||
);
|
||||
await fetch(session.url!, { method: 'PUT', body: 'ab' });
|
||||
started.push(session);
|
||||
}
|
||||
|
||||
const reads = countSessionReads();
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: started.map((session) => ({
|
||||
uploadId: session.sessionId,
|
||||
})),
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(captured.body as Array<{ fsEntry: { path: string } }>).map(
|
||||
(item) => item.fsEntry.path,
|
||||
),
|
||||
).toEqual([
|
||||
`/${username}/Documents/batch-once-a.txt`,
|
||||
`/${username}/Documents/batch-once-b.txt`,
|
||||
]);
|
||||
for (const session of started) {
|
||||
expect(reads.of(session.sessionId)).toBe(2);
|
||||
}
|
||||
} finally {
|
||||
reads.restore();
|
||||
}
|
||||
for (const session of started) {
|
||||
await expect(
|
||||
server.stores.fsEntry.getPendingEntryBySessionId(
|
||||
session.sessionId,
|
||||
),
|
||||
).resolves.toMatchObject({ status: 'completed' });
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves a malformed id in a batch to the service', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const started = await startSignedWrite(
|
||||
actor,
|
||||
`/${username}/Documents/batch-malformed.txt`,
|
||||
2,
|
||||
);
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: [
|
||||
{ uploadId: started.sessionId },
|
||||
{ uploadId: '' },
|
||||
],
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
// The access check reads the session before the status decision; another
|
||||
// request that settles it in between must still turn this one away.
|
||||
const settleDuringAccessCheck = (settle: () => Promise<void>) => {
|
||||
const fs = server.services.fs;
|
||||
const read = fs.getUploadSessions.bind(fs);
|
||||
return vi
|
||||
.spyOn(fs, 'getUploadSessions')
|
||||
.mockImplementationOnce(async (userId, uploadIds) => {
|
||||
const sessions = await read(userId, uploadIds);
|
||||
await settle();
|
||||
return sessions;
|
||||
});
|
||||
};
|
||||
|
||||
it('turns away a completion whose session another one finished during the access check', async () => {
|
||||
const { actor, userId, username } = await makeUser();
|
||||
const target = `/${username}/Documents/raced-complete.txt`;
|
||||
const started = await startSignedWrite(actor, target, 4);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'wxyz' });
|
||||
|
||||
const spy = settleDuringAccessCheck(async () => {
|
||||
await server.services.fs.completeUrlWrite(userId, {
|
||||
uploadId: started.sessionId,
|
||||
});
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.completeWrite(
|
||||
makeReq<CompleteWriteRequest>({
|
||||
body: { uploadId: started.sessionId },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
message: expect.stringContaining('not pending'),
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
await expect(
|
||||
server.stores.fsEntry.getPendingEntryBySessionId(started.sessionId),
|
||||
).resolves.toMatchObject({ status: 'completed' });
|
||||
// A completed session's object backs the live file; a late abort must
|
||||
// leave it alone.
|
||||
await server.services.fs.abortUrlWrite(userId, started.sessionId);
|
||||
const entry = (await server.stores.fsEntry.getEntryByPath(target))!;
|
||||
const { body } = await server.services.fs.readContent(entry);
|
||||
expect(await readAll(body)).toBe('wxyz');
|
||||
});
|
||||
|
||||
it('turns away a completion whose session was aborted during the access check', async () => {
|
||||
const { actor, userId, username } = await makeUser();
|
||||
const target = `/${username}/Documents/raced-abort.txt`;
|
||||
const started = await startSignedWrite(actor, target, 4);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'wxyz' });
|
||||
|
||||
const spy = settleDuringAccessCheck(() =>
|
||||
server.services.fs.abortUrlWrite(userId, started.sessionId),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.completeWrite(
|
||||
makeReq<CompleteWriteRequest>({
|
||||
body: { uploadId: started.sessionId },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
message: expect.stringContaining('not pending'),
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
await expect(
|
||||
server.stores.fsEntry.getPendingEntryBySessionId(started.sessionId),
|
||||
).resolves.toMatchObject({
|
||||
status: 'aborted',
|
||||
failureReason: 'Upload aborted by caller',
|
||||
});
|
||||
await expect(
|
||||
server.stores.fsEntry.getEntryByPath(target),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('turns away a batch completion whose session another one finished during the access check', async () => {
|
||||
const { actor, userId, username } = await makeUser();
|
||||
const target = `/${username}/Documents/raced-batch.txt`;
|
||||
const started = await startSignedWrite(actor, target, 2);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'ab' });
|
||||
|
||||
const spy = settleDuringAccessCheck(async () => {
|
||||
await server.services.fs.batchCompleteUrlWrite(userId, [
|
||||
{ uploadId: started.sessionId },
|
||||
]);
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: [{ uploadId: started.sessionId }],
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
message: expect.stringContaining('not pending'),
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
await expect(
|
||||
server.stores.fsEntry.getPendingEntryBySessionId(started.sessionId),
|
||||
).resolves.toMatchObject({ status: 'completed' });
|
||||
});
|
||||
});
|
||||
|
||||
const readAll = async (body: Readable): Promise<string> => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of body) chunks.push(Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
};
|
||||
|
||||
/** Counts system KV reads of each upload session's key. */
|
||||
const countSessionReads = () => {
|
||||
const get = vi.spyOn(server.stores.kv, 'get');
|
||||
return {
|
||||
of: (sessionId: string) =>
|
||||
get.mock.calls
|
||||
.flatMap(([params]) =>
|
||||
[(params as { key: unknown }).key].flat(),
|
||||
)
|
||||
.filter((key) => key === toPendingUploadSessionKey(sessionId))
|
||||
.length,
|
||||
restore: () => get.mockRestore(),
|
||||
};
|
||||
};
|
||||
|
||||
describe('FSController.abortWrite', () => {
|
||||
it('rejects a request with no uploadId', async () => {
|
||||
const { actor } = await makeUser();
|
||||
@@ -1645,48 +1381,6 @@ describe('FSController.signMultipartParts', () => {
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('reads the session once to sign more parts', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const { res: startRes, captured: startCaptured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.startWrite(
|
||||
makeReq<SignedWriteRequest>({
|
||||
body: {
|
||||
fileMetadata: {
|
||||
path: `/${username}/Documents/sign-once.bin`,
|
||||
size: 8,
|
||||
},
|
||||
uploadMode: 'multipart',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
startRes,
|
||||
),
|
||||
);
|
||||
const started = startCaptured.body as ClientSignedWriteResponse;
|
||||
|
||||
const reads = countSessionReads();
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.signMultipartParts(
|
||||
makeReq<SignMultipartPartsRequest>({
|
||||
body: { uploadId: started.sessionId, partNumbers: [1] },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(captured.body).toMatchObject({
|
||||
uploadId: started.sessionId,
|
||||
multipartPartUrls: [expect.anything()],
|
||||
});
|
||||
expect(reads.of(started.sessionId)).toBe(1);
|
||||
} finally {
|
||||
reads.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// The session is unknown, so reaching the lookup would answer 404.
|
||||
it.each([
|
||||
[FS_MULTIPART_MAX_PARTS + 1, 400],
|
||||
@@ -1753,66 +1447,6 @@ describe('FSController.batchWrites (json)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('stores a batch of thumbnails in one update and announces each with its thumbnail', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const names = ['th-a.png', 'th-b.png', 'th-c.png', 'th-none.png'];
|
||||
const thumbnailFor = (name: string) =>
|
||||
name === 'th-none.png'
|
||||
? undefined
|
||||
: `data:image/png;base64,${Buffer.from(name).toString('base64')}`;
|
||||
const writeSpy = vi.spyOn(server.clients.db, 'write');
|
||||
const announced: Array<{ response: Record<string, unknown> }> = [];
|
||||
const listener = (_key: string, data: unknown) => {
|
||||
announced.push(data as { response: Record<string, unknown> });
|
||||
};
|
||||
server.clients.event.on('outer.gui.item.added', listener as never);
|
||||
const { res, captured } = makeRes();
|
||||
let statements: string[];
|
||||
try {
|
||||
await withActor(actor, () =>
|
||||
controller.batchWrites(
|
||||
makeReq<WriteRequest[]>({
|
||||
body: names.map((name) => ({
|
||||
fileMetadata: {
|
||||
path: `/${username}/Documents/${name}`,
|
||||
size: 1,
|
||||
},
|
||||
fileContent: 'x',
|
||||
thumbnailData: thumbnailFor(name),
|
||||
})) as WriteRequest[],
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
statements = writeSpy.mock.calls.map(([sql]) => String(sql));
|
||||
writeSpy.mockRestore();
|
||||
server.clients.event.off('outer.gui.item.added', listener as never);
|
||||
}
|
||||
|
||||
expect(
|
||||
statements.filter((sql) => sql.includes('SET thumbnail')),
|
||||
).toHaveLength(1);
|
||||
const body = captured.body as Array<{
|
||||
fsEntry: { path: string; thumbnail?: string | null };
|
||||
}>;
|
||||
expect(body.map((item) => item.fsEntry.path)).toEqual(
|
||||
names.map((name) => `/${username}/Documents/${name}`),
|
||||
);
|
||||
for (const name of names) {
|
||||
const path = `/${username}/Documents/${name}`;
|
||||
const expected = thumbnailFor(name) ?? null;
|
||||
await expect(
|
||||
server.stores.fsEntry.getEntryByUuidFromPrimary(
|
||||
(await server.stores.fsEntry.getEntryByPath(path))!.uuid,
|
||||
),
|
||||
).resolves.toMatchObject({ thumbnail: expected });
|
||||
const event = announced.find((data) => data.response.path === path);
|
||||
expect(event?.response.thumbnail ?? null).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns [] for a non-array body', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -1,304 +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 { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
/**
|
||||
* The v1 download routes over real HTTP: what reaches the client, and what
|
||||
* happens to the storage stream when the client goes away mid-download.
|
||||
*/
|
||||
describe('legacy file downloads over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const writeFile = async (name: string, content: Buffer) => {
|
||||
const { username } = env.users.user;
|
||||
const user = await env.server.stores.user.getByUsername(username);
|
||||
const path = `/${username}/Documents/${name}`;
|
||||
const { fsEntry } = await env.server.services.fs.write(user!.id, {
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: content.byteLength,
|
||||
contentType: 'application/octet-stream',
|
||||
},
|
||||
fileContent: content,
|
||||
});
|
||||
return { path, uuid: fsEntry.uuid };
|
||||
};
|
||||
|
||||
const signedReadUrl = async (path: string): Promise<URL> => {
|
||||
const signed = await fetch(new URL('/sign', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.users.user.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ items: [{ path, action: 'read' }] }),
|
||||
});
|
||||
expect(signed.status).toBe(200);
|
||||
const readUrl = new URL(
|
||||
(await signed.json()).signatures[0].read_url as string,
|
||||
);
|
||||
return new URL(`${readUrl.pathname}${readUrl.search}`, env.apiOrigin);
|
||||
};
|
||||
|
||||
const accessTokenFor = async (uuid: string): Promise<string> => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
return env.server.services.auth.createAccessToken(
|
||||
{ user } as never,
|
||||
[[`fs:${uuid}:read`]],
|
||||
{ label: 'legacy-download-http' },
|
||||
);
|
||||
};
|
||||
|
||||
type Route = '/read' | '/token-read' | '/file' | '/down';
|
||||
const routes: Route[] = ['/read', '/token-read', '/file', '/down'];
|
||||
|
||||
const requestFor = async (
|
||||
route: Route,
|
||||
file: { path: string; uuid: string },
|
||||
init: RequestInit & { headers?: Record<string, string> } = {},
|
||||
): Promise<Response> => {
|
||||
const { token, apiToken } = env.users.user;
|
||||
let url: URL;
|
||||
let base: RequestInit & { headers?: Record<string, string> } = {};
|
||||
if (route === '/read') {
|
||||
url = new URL('/read', env.apiOrigin);
|
||||
url.searchParams.set('file', file.path);
|
||||
base = { headers: { Authorization: `Bearer ${token}` } };
|
||||
} else if (route === '/token-read') {
|
||||
url = new URL('/token-read', env.apiOrigin);
|
||||
url.searchParams.set('uid', file.uuid);
|
||||
url.searchParams.set('token', await accessTokenFor(file.uuid));
|
||||
} else if (route === '/file') {
|
||||
url = await signedReadUrl(file.path);
|
||||
} else {
|
||||
url = new URL('/down', env.origin);
|
||||
url.searchParams.set('path', file.path);
|
||||
base = {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
};
|
||||
}
|
||||
return fetch(url, {
|
||||
...base,
|
||||
...init,
|
||||
headers: { ...base.headers, ...init.headers },
|
||||
});
|
||||
};
|
||||
|
||||
it.each(routes)(
|
||||
'GET %s returns the stored bytes and headers unchanged',
|
||||
async (route) => {
|
||||
const content = randomBytes(256 * 1024 + 7);
|
||||
const name = `bytes-${route.replace(/\W/g, '')}.bin`;
|
||||
const file = await writeFile(name, content);
|
||||
|
||||
const raw = await requestFor(route, file, {
|
||||
headers: { 'Accept-Encoding': 'identity' },
|
||||
});
|
||||
expect(raw.status).toBe(200);
|
||||
expect(raw.headers.get('content-length')).toBe(
|
||||
String(content.byteLength),
|
||||
);
|
||||
expect(raw.headers.get('content-type')).toBe(
|
||||
'application/octet-stream',
|
||||
);
|
||||
expect(raw.headers.get('content-disposition')).toBe(
|
||||
`${route === '/down' ? 'attachment' : 'inline'}; filename="${name}"`,
|
||||
);
|
||||
expect(raw.headers.get('etag')).toEqual(expect.any(String));
|
||||
expect(raw.headers.get('last-modified')).toEqual(
|
||||
expect.any(String),
|
||||
);
|
||||
expect(Buffer.from(await raw.arrayBuffer()).equals(content)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// Compressed on the wire by default; the bytes still match.
|
||||
const compressed = await requestFor(route, file);
|
||||
expect(compressed.headers.get('content-encoding')).toBe('gzip');
|
||||
expect(
|
||||
Buffer.from(await compressed.arrayBuffer()).equals(content),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
// Storage hands over one chunk and then goes quiet, as a large object
|
||||
// does while the client is still reading.
|
||||
const stallReadsOf = (objectKey: string) => {
|
||||
const s3 = env.server.stores.s3Object;
|
||||
const client = env.server.clients.s3.get(s3.resolveRegion(null));
|
||||
const send = client.send.bind(client);
|
||||
const bodies: Readable[] = [];
|
||||
const spy = vi.spyOn(client, 'send').mockImplementation(((
|
||||
command: unknown,
|
||||
) => {
|
||||
if (
|
||||
!(command instanceof GetObjectCommand) ||
|
||||
command.input.Key !== objectKey
|
||||
) {
|
||||
return send(command as never);
|
||||
}
|
||||
let pushed = false;
|
||||
const body = new Readable({
|
||||
read() {
|
||||
if (!pushed) {
|
||||
pushed = true;
|
||||
this.push(Buffer.alloc(16 * 1024, 1));
|
||||
}
|
||||
},
|
||||
});
|
||||
bodies.push(body);
|
||||
return Promise.resolve({
|
||||
Body: body,
|
||||
ContentLength: 1024 * 1024,
|
||||
ContentType: 'application/octet-stream',
|
||||
});
|
||||
}) as never);
|
||||
return { spy, bodies };
|
||||
};
|
||||
|
||||
it.each(routes)(
|
||||
'GET %s releases the storage stream when the client aborts',
|
||||
async (route) => {
|
||||
const file = await writeFile(
|
||||
`abort-${route.replace(/\W/g, '')}.bin`,
|
||||
Buffer.alloc(1024 * 1024, 1),
|
||||
);
|
||||
const { spy, bodies } = stallReadsOf(file.uuid);
|
||||
try {
|
||||
const abort = new AbortController();
|
||||
// Uncompressed, so the first chunk reaches the client as-is.
|
||||
const response = await requestFor(route, file, {
|
||||
signal: abort.signal,
|
||||
headers: { 'Accept-Encoding': 'identity' },
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const reader = response.body!.getReader();
|
||||
await reader.read();
|
||||
abort.abort();
|
||||
|
||||
expect(bodies).toHaveLength(1);
|
||||
await vi.waitFor(() => expect(bodies[0]!.destroyed).toBe(true));
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
// The server keeps serving after the aborted transfer.
|
||||
const healthy = await writeFile(
|
||||
`after-abort-${route.replace(/\W/g, '')}.txt`,
|
||||
Buffer.from('still fine'),
|
||||
);
|
||||
const next = await requestFor(route, healthy);
|
||||
expect(next.status).toBe(200);
|
||||
expect(await next.text()).toBe('still fine');
|
||||
},
|
||||
);
|
||||
|
||||
// Storage fails, either before its first byte or after one chunk.
|
||||
const failReadsOf = (objectKey: string, sendFirst: boolean) => {
|
||||
const s3 = env.server.stores.s3Object;
|
||||
const client = env.server.clients.s3.get(s3.resolveRegion(null));
|
||||
const send = client.send.bind(client);
|
||||
return vi.spyOn(client, 'send').mockImplementation(((
|
||||
command: unknown,
|
||||
) => {
|
||||
if (
|
||||
!(command instanceof GetObjectCommand) ||
|
||||
command.input.Key !== objectKey
|
||||
) {
|
||||
return send(command as never);
|
||||
}
|
||||
let reads = 0;
|
||||
const body = new Readable({
|
||||
read() {
|
||||
reads++;
|
||||
if (sendFirst && reads === 1) {
|
||||
this.push(Buffer.alloc(16 * 1024, 1));
|
||||
return;
|
||||
}
|
||||
setImmediate(() =>
|
||||
this.destroy(new Error('storage socket reset')),
|
||||
);
|
||||
},
|
||||
});
|
||||
return Promise.resolve({
|
||||
Body: body,
|
||||
ContentLength: 1024 * 1024,
|
||||
ContentType: 'application/octet-stream',
|
||||
});
|
||||
}) as never);
|
||||
};
|
||||
|
||||
it.each(
|
||||
routes.flatMap((route) => [
|
||||
[route, true] as const,
|
||||
[route, false] as const,
|
||||
]),
|
||||
)(
|
||||
'GET %s drops the connection when storage fails (bytes sent first: %s)',
|
||||
async (route, sendFirst) => {
|
||||
const file = await writeFile(
|
||||
`fail-${sendFirst ? 'mid' : 'start'}-${route.replace(/\W/g, '')}.bin`,
|
||||
Buffer.alloc(1024 * 1024, 1),
|
||||
);
|
||||
const spy = failReadsOf(file.uuid, sendFirst);
|
||||
try {
|
||||
// No error body after a partial one: the client sees the
|
||||
// transfer cut off, either at the headers or mid-body.
|
||||
const outcome = await (async () => {
|
||||
try {
|
||||
const response = await requestFor(route, file, {
|
||||
headers: { 'Accept-Encoding': 'identity' },
|
||||
});
|
||||
await response.arrayBuffer();
|
||||
return `completed ${response.status}`;
|
||||
} catch {
|
||||
return 'cut off';
|
||||
}
|
||||
})();
|
||||
expect(outcome).toBe('cut off');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
const healthy = await writeFile(
|
||||
`after-fail-${sendFirst ? 'mid' : 'start'}-${route.replace(/\W/g, '')}.txt`,
|
||||
Buffer.from('still fine'),
|
||||
);
|
||||
const next = await requestFor(route, healthy);
|
||||
expect(next.status).toBe(200);
|
||||
expect(await next.text()).toBe('still fine');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -467,88 +467,6 @@ describe('LegacyFSController.stat', () => {
|
||||
expect(body.size).toBe(0);
|
||||
});
|
||||
|
||||
it('reports is_empty from the same children a listing returns', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const emptyPath = `/${username}/Documents/probe-empty`;
|
||||
const fullPath = `/${username}/Documents/probe-full`;
|
||||
for (const path of [emptyPath, fullPath]) {
|
||||
await withActor(actor, () =>
|
||||
controller.mkdir(
|
||||
makeReq({ body: { path }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
}
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path: `${fullPath}/inner.txt`,
|
||||
size: 5,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: 'hello',
|
||||
} as never);
|
||||
|
||||
for (const path of [emptyPath, fullPath]) {
|
||||
const entry = (await server.stores.fsEntry.getEntryByPath(path))!;
|
||||
const listed = await server.stores.fsEntry.listChildren(
|
||||
entry.uuid,
|
||||
{ limit: 1 },
|
||||
);
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.stat(
|
||||
makeReq({ body: { path, return_size: true }, actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.is_empty).toBe(listed.length === 0);
|
||||
expect(body.size).toBe(path === fullPath ? 5 : 0);
|
||||
expect(body.owner).toEqual({ username });
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces an earlier failure ahead of a failed subtree size, with nothing left unhandled', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const path = `/${username}/Documents/size-order`;
|
||||
await withActor(actor, () =>
|
||||
controller.mkdir(makeReq({ body: { path }, actor }), makeRes().res),
|
||||
);
|
||||
const flagsFailure = new Error('share flags failed');
|
||||
const sizeFailure = new Error('subtree size failed');
|
||||
const flags = vi
|
||||
.spyOn(server.services.share, 'shareFlags')
|
||||
.mockRejectedValueOnce(flagsFailure);
|
||||
const size = vi
|
||||
.spyOn(server.services.fs, 'getSubtreeSize')
|
||||
.mockRejectedValue(sizeFailure);
|
||||
try {
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.stat(
|
||||
makeReq({ body: { path, return_size: true }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toBe(flagsFailure);
|
||||
|
||||
// On its own, the size failure is what the caller sees.
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.stat(
|
||||
makeReq({ body: { path, return_size: true }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toBe(sizeFailure);
|
||||
} finally {
|
||||
flags.mockRestore();
|
||||
size.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports the share flag, and the recipients when asked', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
@@ -3732,63 +3650,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';
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
import { pipeToResponse } from '../../core/http/pipeToResponse.js';
|
||||
import {
|
||||
assertNotSuspended,
|
||||
assertTeam2fa,
|
||||
@@ -53,7 +51,6 @@ import {
|
||||
} from '../../util/fileSigning.js';
|
||||
import { APP_ICON_SIZES } from '../../util/appIcon.js';
|
||||
import { toAppSummary } from '../../util/appView.js';
|
||||
import { awaitLater } from '../../util/concurrency.js';
|
||||
import {
|
||||
joinChildPath,
|
||||
normalizeAbsolutePath,
|
||||
@@ -103,7 +100,6 @@ import {
|
||||
type GuiItemEventName,
|
||||
loadLegacyAssociatedApps,
|
||||
replacedByMove,
|
||||
resolveReadRange,
|
||||
resolveV1Selector,
|
||||
signEntry,
|
||||
signingConfigFromAppConfig,
|
||||
@@ -213,19 +209,12 @@ export class LegacyFSController extends PuterController {
|
||||
// Limits come from `./limits` and carry an explicit `scope`, so these
|
||||
// draw from the same per-user budget as their v2 counterparts rather
|
||||
// than handing a caller a second allowance for the same operation.
|
||||
//
|
||||
// The entry routes — reads and writes alike — admit scoped access
|
||||
// tokens: the ACL check each handler runs already intersects a token's
|
||||
// grant with its issuer's, so the token reaches exactly what it was
|
||||
// minted for. That is what a background events handler acts through —
|
||||
// its `user` is a token scoped to the subscription's grant — and it
|
||||
// matches what WebDAV admits. Routes reaching past a single entry
|
||||
// (search, df, sign, the app helpers) stay closed to them.
|
||||
const mutate = {
|
||||
...apiOptions,
|
||||
allowAccessToken: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
} as RouteOptions;
|
||||
const mutate = { ...apiOptions, rateLimit: FS_MUTATE_LIMIT };
|
||||
// Read-side routes admit scoped access tokens: the ACL check each
|
||||
// handler runs already intersects a token's grant with its issuer's,
|
||||
// so the token reaches exactly what it was minted for. That is what a
|
||||
// background events handler acts through — its `user` is a token
|
||||
// scoped to the subscription's grant.
|
||||
const reads = { ...apiOptions, allowAccessToken: true } as RouteOptions;
|
||||
router.post('/stat', { ...reads, rateLimit: FS_STAT_LIMIT }, this.stat);
|
||||
router.post(
|
||||
@@ -275,8 +264,6 @@ export class LegacyFSController extends PuterController {
|
||||
'/batch',
|
||||
{
|
||||
...spends,
|
||||
// Every op authorizes its own target.
|
||||
allowAccessToken: true,
|
||||
rateLimit: FS_BATCH_LIMIT,
|
||||
concurrent: FS_BATCH_CONCURRENT,
|
||||
},
|
||||
@@ -507,16 +494,6 @@ export class LegacyFSController extends PuterController {
|
||||
'see',
|
||||
);
|
||||
|
||||
// Started early but awaited where it used to run, so its error still
|
||||
// surfaces after the ones below. awaitLater covers the early exits
|
||||
// that never reach that await.
|
||||
const subtreeSize =
|
||||
entry.isDir && getBoolean(body, 'return_size')
|
||||
? awaitLater(
|
||||
this.services.fs.getSubtreeSize(userId, entry.path),
|
||||
)
|
||||
: null;
|
||||
|
||||
const [suggestedApps, appsById, shareFlags, parentUid] =
|
||||
await Promise.all([
|
||||
this.services.suggestedApps.getSuggestedApps(entry),
|
||||
@@ -544,7 +521,12 @@ export class LegacyFSController extends PuterController {
|
||||
});
|
||||
|
||||
// Optional hydrations:
|
||||
if (subtreeSize) shaped.size = await subtreeSize;
|
||||
if (entry.isDir && getBoolean(body, 'return_size')) {
|
||||
shaped.size = await this.services.fs.getSubtreeSize(
|
||||
userId,
|
||||
entry.path,
|
||||
);
|
||||
}
|
||||
// `return_versions` has no parity yet; the empty array keeps `forEach`
|
||||
// callers working. `return_owner` is a no-op — `owner` is always set.
|
||||
if (getBoolean(body, 'return_versions')) shaped.versions = [];
|
||||
@@ -932,9 +914,7 @@ export class LegacyFSController extends PuterController {
|
||||
// Trash, and `null`/`{}` when restoring. See
|
||||
// `src/gui/src/helpers.js` → `window.move_items`.
|
||||
newMetadata: (body.new_metadata ?? undefined) as
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined,
|
||||
Record<string, unknown> | null | undefined,
|
||||
});
|
||||
const overwrittenEntry = replacedByMove(overwriteTarget, moved, name);
|
||||
const oldPath = source.path;
|
||||
@@ -1289,7 +1269,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,
|
||||
});
|
||||
@@ -1322,7 +1305,10 @@ export class LegacyFSController extends PuterController {
|
||||
);
|
||||
res.status(range ? 206 : 200);
|
||||
|
||||
void pipeToResponse(download.body, res);
|
||||
download.body.on('error', (err) => {
|
||||
res.destroy(err);
|
||||
});
|
||||
download.body.pipe(res);
|
||||
};
|
||||
|
||||
tokenRead = async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -1347,7 +1333,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);
|
||||
@@ -1405,8 +1391,7 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
type SignedOrEmpty =
|
||||
| (SignedFile & { path?: string })
|
||||
| Record<string, never>;
|
||||
(SignedFile & { path?: string }) | Record<string, never>;
|
||||
const result: { signatures: SignedOrEmpty[]; token?: string } = {
|
||||
signatures: [],
|
||||
};
|
||||
@@ -1454,13 +1439,11 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
// ACL: always require read; downgrade write→read silently.
|
||||
// One descriptor for both modes reads the chain at most once.
|
||||
const descriptor = this.services.acl.fsDescriptor(entry.path);
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
actor,
|
||||
descriptor,
|
||||
entry.path,
|
||||
'read',
|
||||
);
|
||||
let finalAction: 'read' | 'write' = 'read';
|
||||
@@ -1472,7 +1455,7 @@ export class LegacyFSController extends PuterController {
|
||||
// publishes the descriptor, so there is nothing to hide.
|
||||
const writeOk = await this.services.acl.check(
|
||||
actor,
|
||||
descriptor,
|
||||
this.services.acl.fsDescriptor(entry.path),
|
||||
'write',
|
||||
);
|
||||
finalAction = writeOk ? 'write' : 'read';
|
||||
@@ -1802,7 +1785,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,
|
||||
});
|
||||
@@ -1833,7 +1819,10 @@ export class LegacyFSController extends PuterController {
|
||||
);
|
||||
res.status(range ? 206 : 200);
|
||||
|
||||
void pipeToResponse(download.body, res);
|
||||
download.body.on('error', (err) => {
|
||||
res.destroy(err);
|
||||
});
|
||||
download.body.pipe(res);
|
||||
};
|
||||
|
||||
/** GET|POST /df — user storage allowance. */
|
||||
@@ -1997,10 +1986,7 @@ export class LegacyFSController extends PuterController {
|
||||
const subjectRef = body.subject;
|
||||
const appRef = body.app;
|
||||
const mode = (getString(body, 'mode') ?? 'read') as
|
||||
| 'see'
|
||||
| 'list'
|
||||
| 'read'
|
||||
| 'write';
|
||||
'see' | 'list' | 'read' | 'write';
|
||||
if (!subjectRef || !appRef)
|
||||
throw new HttpError(400, '`subject` and `app` are required', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -2106,7 +2092,10 @@ export class LegacyFSController extends PuterController {
|
||||
);
|
||||
res.status(range ? 206 : 200);
|
||||
|
||||
void pipeToResponse(download.body, res);
|
||||
download.body.on('error', (err) => {
|
||||
res.destroy(err);
|
||||
});
|
||||
download.body.pipe(res);
|
||||
};
|
||||
|
||||
async #emitGuiEvent(
|
||||
|
||||
@@ -17,16 +17,13 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { createTestUser, setupTestServer } from '../../testUtil.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
asRecord,
|
||||
fsEntryMimeType,
|
||||
getBoolean,
|
||||
getString,
|
||||
loadLegacyAssociatedApps,
|
||||
resolveReadRange,
|
||||
signEntry,
|
||||
signEntryThumbnail,
|
||||
signingConfigFromAppConfig,
|
||||
@@ -363,7 +360,7 @@ describe('toLegacyEntry', () => {
|
||||
|
||||
it('probes for children to set is_empty on a directory', async () => {
|
||||
const fsEntryStore = {
|
||||
hasChildrenByParentUid: async () => true,
|
||||
listChildren: async () => [{ uuid: 'child' }],
|
||||
} as never;
|
||||
const shaped = await toLegacyEntry(
|
||||
undefined,
|
||||
@@ -376,7 +373,7 @@ describe('toLegacyEntry', () => {
|
||||
|
||||
it('treats a failed child probe as non-empty rather than throwing', async () => {
|
||||
const fsEntryStore = {
|
||||
hasChildrenByParentUid: async () => {
|
||||
listChildren: async () => {
|
||||
throw new Error('db down');
|
||||
},
|
||||
} as never;
|
||||
@@ -388,61 +385,6 @@ describe('toLegacyEntry', () => {
|
||||
expect(shaped.is_empty).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves is_empty unset for a directory with no store to ask', async () => {
|
||||
const shaped = await toLegacyEntry(
|
||||
undefined,
|
||||
baseEntry({ isDir: true }),
|
||||
);
|
||||
expect(shaped).not.toHaveProperty('is_empty');
|
||||
});
|
||||
|
||||
it('runs the child probe, owner and thumbnail lookups together, keeping key order', async () => {
|
||||
const started: string[] = [];
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const fsEntryStore = {
|
||||
hasChildrenByParentUid: async () => {
|
||||
started.push('children');
|
||||
await gate;
|
||||
return false;
|
||||
},
|
||||
} as never;
|
||||
const userStore = {
|
||||
getById: async () => {
|
||||
started.push('owner');
|
||||
await gate;
|
||||
return { username: 'alice' };
|
||||
},
|
||||
};
|
||||
const eventClient = {
|
||||
emitAndWait: async (_key: string, data: { thumbnail: string }) => {
|
||||
started.push('thumbnail');
|
||||
await gate;
|
||||
data.thumbnail = 'https://signed.example/t.png';
|
||||
},
|
||||
} as never;
|
||||
|
||||
const pending = toLegacyEntry(
|
||||
eventClient,
|
||||
baseEntry({ isDir: true, thumbnail: 's3://bucket/t.png' }),
|
||||
{ fsEntryStore, userStore },
|
||||
);
|
||||
await vi.waitFor(() => expect(started).toHaveLength(3));
|
||||
release();
|
||||
const shaped = await pending;
|
||||
|
||||
expect(shaped).toMatchObject({
|
||||
is_empty: true,
|
||||
owner: { username: 'alice' },
|
||||
thumbnail: 'https://signed.example/t.png',
|
||||
});
|
||||
const keys = Object.keys(shaped);
|
||||
expect(keys.slice(-2)).toEqual(['is_empty', 'owner']);
|
||||
expect(keys.indexOf('thumbnail')).toBe(keys.indexOf('is_public') + 1);
|
||||
});
|
||||
|
||||
it('hydrates the owner as a username-only object', async () => {
|
||||
const userStore = {
|
||||
getById: async () => ({ username: 'alice', email: '[email protected]' }),
|
||||
@@ -580,128 +522,3 @@ describe('signEntry', () => {
|
||||
expect(inSeconds(signed.expires)).toBeLessThanOrEqual(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toLegacyEntry is_empty against stored children', () => {
|
||||
let server: PuterServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('reports an empty directory as empty, and one with a child as not', async () => {
|
||||
const username = `lfh${Math.random().toString(36).slice(2, 10)}`;
|
||||
await createTestUser(server, { username, password: 'lfh-password' });
|
||||
const fsEntryStore = server.stores.fsEntry;
|
||||
const dir = await fsEntryStore.createNonFileEntry({
|
||||
parent: (await fsEntryStore.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
))!,
|
||||
name: 'maybe-empty',
|
||||
kind: 'directory',
|
||||
});
|
||||
const shape = async () =>
|
||||
toLegacyEntry(
|
||||
undefined,
|
||||
(await fsEntryStore.getEntryByUuid(dir.uuid))!,
|
||||
{ fsEntryStore },
|
||||
);
|
||||
|
||||
expect((await shape()).is_empty).toBe(true);
|
||||
await fsEntryStore.createNonFileEntry({
|
||||
parent: dir,
|
||||
name: 'child',
|
||||
kind: 'directory',
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
type ACLService,
|
||||
type AclMode,
|
||||
type AclRefusalOptions,
|
||||
type ResourceDescriptor,
|
||||
} from '../../services/acl/ACLService.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
@@ -99,57 +98,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).
|
||||
@@ -181,7 +129,7 @@ const MAX_SELECTOR_DEPTH = 4;
|
||||
* Resolve `ref`, reporting any miss as the same 404 an unreadable entry gets,
|
||||
* so a lookup never echoes a resolved path or tells missing from hidden.
|
||||
*/
|
||||
export async function resolveOrNotFound(
|
||||
async function resolveOrNotFound(
|
||||
fsEntryStore: FSEntryStore,
|
||||
ref: NodeRef,
|
||||
): Promise<FSEntry> {
|
||||
@@ -281,7 +229,7 @@ export async function assertAccess(
|
||||
aclService: ACLService,
|
||||
_fsService: FSService,
|
||||
actor: Actor,
|
||||
path: string | ResourceDescriptor,
|
||||
path: string,
|
||||
mode: AclMode,
|
||||
): Promise<void> {
|
||||
await aclService.assertFsAccess(actor, path, mode, LEGACY_REFUSAL);
|
||||
@@ -526,46 +474,39 @@ export async function toLegacyEntry(
|
||||
...(opts.isShared === undefined ? {} : { is_shared: opts.isShared }),
|
||||
};
|
||||
|
||||
const { fsEntryStore, userStore } = opts;
|
||||
const [isEmpty, ownerUsername, thumbnail] = await Promise.all([
|
||||
// `is_empty` — only meaningful for directories.
|
||||
entry.isDir && fsEntryStore
|
||||
? (async () => {
|
||||
try {
|
||||
return !(await fsEntryStore.hasChildrenByParentUid(
|
||||
entry.uuid,
|
||||
));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})()
|
||||
: undefined,
|
||||
// `owner` — username-only. Matches the legacy safe-entry contract.
|
||||
userStore
|
||||
? (async () => {
|
||||
try {
|
||||
const owner = await userStore.getById(entry.userId);
|
||||
return owner && typeof owner.username === 'string'
|
||||
? owner.username
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined; // best-effort
|
||||
}
|
||||
})()
|
||||
: undefined,
|
||||
// Let the thumbnail extension swap an s3:// key for a signed URL.
|
||||
signEntryThumbnail(
|
||||
eventClient,
|
||||
entry.uuid,
|
||||
response.thumbnail as string | null,
|
||||
),
|
||||
]);
|
||||
// `is_empty` — only meaningful for directories. Single-row probe so we
|
||||
// don't pay for listing every child.
|
||||
if (entry.isDir && opts.fsEntryStore) {
|
||||
try {
|
||||
const children = await opts.fsEntryStore.listChildren(entry.uuid, {
|
||||
limit: 1,
|
||||
});
|
||||
response.is_empty = children.length === 0;
|
||||
} catch {
|
||||
response.is_empty = false;
|
||||
}
|
||||
} else if (!entry.isDir) {
|
||||
response.is_empty = false;
|
||||
}
|
||||
|
||||
if (isEmpty !== undefined) response.is_empty = isEmpty;
|
||||
else if (!entry.isDir) response.is_empty = false;
|
||||
if (ownerUsername !== undefined)
|
||||
response.owner = { username: ownerUsername };
|
||||
response.thumbnail = thumbnail;
|
||||
// `owner` — username-only. Matches the legacy safe-entry contract.
|
||||
if (opts.userStore) {
|
||||
try {
|
||||
const owner = await opts.userStore.getById(entry.userId);
|
||||
if (owner && typeof owner.username === 'string') {
|
||||
response.owner = { username: owner.username };
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
// Let the thumbnail extension swap an s3:// key for a signed URL.
|
||||
response.thumbnail = await signEntryThumbnail(
|
||||
eventClient,
|
||||
entry.uuid,
|
||||
response.thumbnail as string | null,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
// A scoped access token writes through the `/fs` routes wherever its grant
|
||||
// reaches — the reach it already has over WebDAV — and nowhere else. The ACL
|
||||
// each handler runs intersects the token's grant with its issuer's, so these
|
||||
// check both edges of that, plus the routes that stay closed to tokens.
|
||||
describe('scoped access tokens on the /fs write routes', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const post = async (route: string, token: string, body: unknown) => {
|
||||
const res = await fetch(new URL(route, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let json: Record<string, unknown> | null = null;
|
||||
try {
|
||||
json = text ? (JSON.parse(text) as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
// Some refusals are plain text.
|
||||
}
|
||||
return { status: res.status, json, text };
|
||||
};
|
||||
|
||||
/** A user, a token granted `mode` on their Documents, and some fixtures. */
|
||||
const setup = async (mode: 'read' | 'write') => {
|
||||
const user = await createTestUser(env.server, {
|
||||
username: `stw${Math.random().toString(36).slice(2, 9)}`,
|
||||
password: 'puter-test-user-password',
|
||||
});
|
||||
const home = `/${user.username}`;
|
||||
const documents = await post('/stat', user.token, {
|
||||
path: `${home}/Documents`,
|
||||
});
|
||||
await post('/touch', user.token, { path: `${home}/Desktop/kept.txt` });
|
||||
await post('/touch', user.token, { path: `${home}/Documents/in.txt` });
|
||||
const minted = await post('/auth/create-access-token', user.token, {
|
||||
permissions: [`fs:${documents.json!.uid}:${mode}`],
|
||||
});
|
||||
expect(minted.status).toBe(200);
|
||||
return {
|
||||
user,
|
||||
home,
|
||||
token: minted.json!.token as string,
|
||||
exists: async (path: string) =>
|
||||
(await post('/stat', user.token, { path })).status === 200,
|
||||
};
|
||||
};
|
||||
|
||||
it('writes inside its grant on the legacy routes', async () => {
|
||||
const { home, token, exists } = await setup('write');
|
||||
const docs = `${home}/Documents`;
|
||||
|
||||
expect(
|
||||
(await post('/mkdir', token, { path: `${docs}/d` })).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await post('/touch', token, { path: `${docs}/d/t.txt` })).status,
|
||||
).toBe(200);
|
||||
const t = await post('/stat', token, { path: `${docs}/d/t.txt` });
|
||||
expect(
|
||||
(
|
||||
await post('/rename', token, {
|
||||
uid: t.json!.uid,
|
||||
new_name: 'r.txt',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/copy', token, {
|
||||
source: `${docs}/d/r.txt`,
|
||||
destination: docs,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/move', token, {
|
||||
source: `${docs}/r.txt`,
|
||||
destination: `${docs}/d`,
|
||||
new_name: 'm.txt',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await post('/delete', token, { paths: [`${docs}/d/m.txt`] }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
const batch = await post('/batch', token, {
|
||||
operations: [
|
||||
{ op: 'mkdir', path: `${docs}/b` },
|
||||
{ op: 'delete', path: `${docs}/d/r.txt` },
|
||||
],
|
||||
});
|
||||
expect(batch.status, batch.text).toBe(200);
|
||||
|
||||
expect(await exists(`${docs}/b`)).toBe(true);
|
||||
expect(await exists(`${docs}/d/r.txt`)).toBe(false);
|
||||
expect(await exists(`${docs}/d/m.txt`)).toBe(false);
|
||||
}, 180_000);
|
||||
|
||||
it('writes inside its grant on the v2 routes', async () => {
|
||||
const { home, token, exists } = await setup('write');
|
||||
const docs = `${home}/Documents`;
|
||||
|
||||
expect(
|
||||
(await post('/fs/mkdir', token, { path: `${docs}/v` })).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await post('/fs/touch', token, { path: `${docs}/v/t.txt` }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
const written = await post('/fs/write', token, {
|
||||
fileMetadata: {
|
||||
path: `${docs}/v/w.txt`,
|
||||
size: 3,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: 'abc',
|
||||
});
|
||||
expect(written.status, written.text).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/rename', token, {
|
||||
path: `${docs}/v/t.txt`,
|
||||
new_name: 'r.txt',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/copy', token, {
|
||||
source: `${docs}/v/r.txt`,
|
||||
destination: docs,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/move', token, {
|
||||
source: `${docs}/r.txt`,
|
||||
destination: `${docs}/v`,
|
||||
new_name: 'm.txt',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/mkshortcut', token, {
|
||||
parent: docs,
|
||||
target: `${docs}/v/w.txt`,
|
||||
name: 'w-link',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await post('/fs/delete', token, { path: `${docs}/v/m.txt` }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
const started = await post('/fs/startWrite', token, {
|
||||
fileMetadata: { path: `${docs}/v/up.bin`, size: 4 },
|
||||
});
|
||||
expect(started.status, started.text).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/abortWrite', token, {
|
||||
uploadId: started.json!.sessionId,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
expect(await exists(`${docs}/v/w.txt`)).toBe(true);
|
||||
expect(await exists(`${docs}/w-link`)).toBe(true);
|
||||
expect(await exists(`${docs}/v/m.txt`)).toBe(false);
|
||||
}, 180_000);
|
||||
|
||||
it('is refused everywhere outside its grant', async () => {
|
||||
const { home, token, exists } = await setup('write');
|
||||
const docs = `${home}/Documents`;
|
||||
const desktop = `${home}/Desktop`;
|
||||
|
||||
const attempts = [
|
||||
['/mkdir', { path: `${desktop}/x` }],
|
||||
['/touch', { path: `${desktop}/x.txt` }],
|
||||
['/move', { source: `${docs}/in.txt`, destination: desktop }],
|
||||
['/copy', { source: `${desktop}/kept.txt`, destination: docs }],
|
||||
['/delete', { paths: [`${desktop}/kept.txt`] }],
|
||||
['/delete', { paths: [home] }],
|
||||
['/fs/mkdir', { path: `${desktop}/x` }],
|
||||
[
|
||||
'/fs/write',
|
||||
{
|
||||
fileMetadata: {
|
||||
path: `${desktop}/kept.txt`,
|
||||
size: 1,
|
||||
overwrite: true,
|
||||
},
|
||||
fileContent: 'x',
|
||||
},
|
||||
],
|
||||
['/fs/move', { source: `${docs}/in.txt`, destination: desktop }],
|
||||
['/fs/copy', { source: `${desktop}/kept.txt`, destination: docs }],
|
||||
['/fs/rename', { path: `${desktop}/kept.txt`, new_name: 'y.txt' }],
|
||||
['/fs/delete', { path: `${desktop}/kept.txt` }],
|
||||
['/fs/delete', { path: home }],
|
||||
] as const;
|
||||
for (const [route, body] of attempts) {
|
||||
const res = await post(route, token, body);
|
||||
expect(
|
||||
[403, 404],
|
||||
`${route} ${JSON.stringify(body)} → ${res.status}`,
|
||||
).toContain(res.status);
|
||||
}
|
||||
|
||||
const batch = await post('/batch', token, {
|
||||
operations: [{ op: 'mkdir', path: `${desktop}/b` }],
|
||||
});
|
||||
expect(batch.status).not.toBe(200);
|
||||
|
||||
expect(await exists(`${desktop}/kept.txt`)).toBe(true);
|
||||
expect(await exists(`${desktop}/x`)).toBe(false);
|
||||
expect(await exists(`${desktop}/b`)).toBe(false);
|
||||
expect(await exists(`${docs}/in.txt`)).toBe(true);
|
||||
expect(await exists(`${docs}/kept.txt`)).toBe(false);
|
||||
}, 180_000);
|
||||
|
||||
it('refuses every write to a read-only token', async () => {
|
||||
const { home, token, exists } = await setup('read');
|
||||
const docs = `${home}/Documents`;
|
||||
|
||||
const attempts = [
|
||||
['/mkdir', { path: `${docs}/x` }],
|
||||
['/touch', { path: `${docs}/x.txt` }],
|
||||
['/delete', { paths: [`${docs}/in.txt`] }],
|
||||
['/fs/mkdir', { path: `${docs}/x` }],
|
||||
[
|
||||
'/fs/write',
|
||||
{
|
||||
fileMetadata: { path: `${docs}/x.txt`, size: 1 },
|
||||
fileContent: 'x',
|
||||
},
|
||||
],
|
||||
[
|
||||
'/fs/startWrite',
|
||||
{ fileMetadata: { path: `${docs}/x.bin`, size: 1 } },
|
||||
],
|
||||
['/fs/rename', { path: `${docs}/in.txt`, new_name: 'y.txt' }],
|
||||
['/fs/delete', { path: `${docs}/in.txt` }],
|
||||
] as const;
|
||||
for (const [route, body] of attempts) {
|
||||
const res = await post(route, token, body);
|
||||
expect(res.status, `${route} → ${res.status}`).toBe(403);
|
||||
}
|
||||
expect(await exists(`${docs}/in.txt`)).toBe(true);
|
||||
expect(await exists(`${docs}/x`)).toBe(false);
|
||||
}, 180_000);
|
||||
|
||||
// An upload session belongs to the account, not to the credential that
|
||||
// opened it, so each later step re-checks the target against the caller.
|
||||
it('cannot touch an upload the session opened outside its grant', async () => {
|
||||
const { user, home, token } = await setup('write');
|
||||
const started = await post('/fs/startWrite', user.token, {
|
||||
fileMetadata: { path: `${home}/Desktop/up.bin`, size: 4 },
|
||||
});
|
||||
expect(started.status).toBe(200);
|
||||
const uploadId = started.json!.sessionId as string;
|
||||
|
||||
for (const [route, body] of [
|
||||
['/fs/abortWrite', { uploadId }],
|
||||
['/fs/completeWrite', { uploadId }],
|
||||
['/fs/signMultipartParts', { uploadId, partNumbers: [1] }],
|
||||
] as const) {
|
||||
const res = await post(route, token, body);
|
||||
expect(
|
||||
[403, 404],
|
||||
`${route} → ${res.status} ${res.text}`,
|
||||
).toContain(res.status);
|
||||
}
|
||||
|
||||
// Still open: the session's own abort finds it to abort.
|
||||
const sessions = await env.server.services.fs.getUploadSessions(
|
||||
(await env.server.stores.user.getByUsername(user.username))!.id,
|
||||
[uploadId],
|
||||
);
|
||||
expect(sessions[0]?.status).not.toBe('aborted');
|
||||
expect(
|
||||
(await post('/fs/abortWrite', user.token, { uploadId })).status,
|
||||
).toBe(200);
|
||||
}, 180_000);
|
||||
|
||||
it('aborting an unknown upload stays a no-op', async () => {
|
||||
const { token } = await setup('write');
|
||||
const res = await post('/fs/abortWrite', token, {
|
||||
uploadId: 'no-such-upload',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
}, 180_000);
|
||||
|
||||
it('keeps the routes that reach past one entry closed', async () => {
|
||||
const { home, token } = await setup('write');
|
||||
const docs = `${home}/Documents`;
|
||||
|
||||
for (const [route, body] of [
|
||||
['/sign', { items: [{ path: `${docs}/in.txt`, action: 'read' }] }],
|
||||
['/search', { text: 'in' }],
|
||||
['/fs/search', { query: 'in' }],
|
||||
['/df', {}],
|
||||
['/readdir-subdomains', {}],
|
||||
] as const) {
|
||||
const res = await post(route, token, body);
|
||||
expect(res.status, `${route} → ${res.status}`).toBe(403);
|
||||
expect(res.json?.message ?? res.text).toMatch(
|
||||
/Access tokens are not allowed/,
|
||||
);
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -194,87 +194,4 @@ describe('writes inside a folder shared with write', () => {
|
||||
(moved.json!.moved as { path: string }).path,
|
||||
).toBe(`${s.maskedDir}/renamed.txt`);
|
||||
}, 180_000);
|
||||
|
||||
// The thumbnail update is scoped to the writer's own rows, so a file the
|
||||
// owner ends up owning keeps none, while the writer's own file in the same
|
||||
// batch gets its thumbnail.
|
||||
it('batchWrite stores thumbnails on the writer’s own files only', async () => {
|
||||
const s = await shareAFolder();
|
||||
const png =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
||||
const ownPath = `/${s.recipient.username}/Documents/own.png`;
|
||||
const written = await post('/fs/batchWrite', s.recipient.token, [
|
||||
{
|
||||
fileMetadata: { path: `${s.maskedDir}/shared.png`, size: 1 },
|
||||
fileContent: 'a',
|
||||
thumbnailData: png,
|
||||
},
|
||||
{
|
||||
fileMetadata: { path: ownPath, size: 1 },
|
||||
fileContent: 'b',
|
||||
thumbnailData: png,
|
||||
},
|
||||
]);
|
||||
expect(written.status).toBe(200);
|
||||
|
||||
const stored = async (path: string) =>
|
||||
env.server.stores.fsEntry.getEntryByUuidFromPrimary(
|
||||
(await env.server.stores.fsEntry.getEntryByPath(path))!.uuid,
|
||||
);
|
||||
expect((await stored(`${s.realDir}/shared.png`))?.thumbnail).toBeNull();
|
||||
expect((await stored(ownPath))?.thumbnail).toMatch(/^s3:\/\//);
|
||||
}, 180_000);
|
||||
|
||||
// The recipient only knows the masked path, so refusals name entries the
|
||||
// way the recipient addressed them.
|
||||
it('keeps the owner’s real path out of error messages', async () => {
|
||||
const s = await shareAFolder();
|
||||
await post('/touch', s.owner.token, {
|
||||
path: `${s.realDir}/other.txt`,
|
||||
});
|
||||
|
||||
const cases = [
|
||||
{
|
||||
res: await post('/fs/stat', s.recipient.token, {
|
||||
path: `${s.maskedDir}/missing.txt`,
|
||||
}),
|
||||
status: 404,
|
||||
message: 'Subject does not exist',
|
||||
},
|
||||
{
|
||||
res: await post('/fs/mkdir', s.recipient.token, {
|
||||
path: `${s.maskedDir}/note.txt`,
|
||||
}),
|
||||
status: 409,
|
||||
message: 'An entry named `note.txt` already exists',
|
||||
},
|
||||
{
|
||||
res: await post('/fs/mkdir', s.recipient.token, {
|
||||
path: `${s.maskedDir}/missing/child`,
|
||||
}),
|
||||
status: 404,
|
||||
message: 'Parent path does not exist: missing',
|
||||
},
|
||||
{
|
||||
res: await post('/fs/rename', s.recipient.token, {
|
||||
uid: s.noteUid,
|
||||
new_name: 'other.txt',
|
||||
}),
|
||||
status: 409,
|
||||
message: 'An entry named `other.txt` already exists',
|
||||
},
|
||||
{
|
||||
res: await post('/mkdir', s.recipient.token, {
|
||||
path: `${s.maskedDir}/note.txt`,
|
||||
}),
|
||||
status: 409,
|
||||
message: 'An entry named `note.txt` already exists',
|
||||
},
|
||||
];
|
||||
for (const { res, status, message } of cases) {
|
||||
expect(res.status).toBe(status);
|
||||
expect(res.json!.message).toBe(message);
|
||||
expect(JSON.stringify(res.json)).not.toContain(s.realDir);
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,140 +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 { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import type { ProfileController } from './ProfileController.js';
|
||||
|
||||
// GET /profile without a username: the caller's own profile, read alongside
|
||||
// their account row.
|
||||
describe('ProfileController own profile', () => {
|
||||
let server: PuterServer;
|
||||
let controller: ProfileController;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
controller = server.controllers.profile as unknown as ProfileController;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const makeUser = async (): Promise<UserRow> => {
|
||||
const username = `pc-${Math.random().toString(36).slice(2, 10)}`;
|
||||
return server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
requires_email_confirmation: false,
|
||||
} as Parameters<typeof server.stores.user.create>[0]);
|
||||
};
|
||||
|
||||
const getOwn = async (uuid: string) => {
|
||||
let body: unknown;
|
||||
const res = {
|
||||
json: (value: unknown) => {
|
||||
body = value;
|
||||
},
|
||||
} as unknown as Response;
|
||||
await controller.get(
|
||||
{ query: {}, actor: { user: { uuid } } } as unknown as Request,
|
||||
res,
|
||||
);
|
||||
return body;
|
||||
};
|
||||
|
||||
const isProfilePath = (path: unknown) =>
|
||||
String(path).startsWith('/system/profiles/');
|
||||
|
||||
it('starts reading the profile before the account lookup returns', async () => {
|
||||
const user = await makeUser();
|
||||
await server.services.profile.updateProfile(user, { bio: 'hi' });
|
||||
|
||||
const order: string[] = [];
|
||||
const getByUuid = server.stores.user.getByUuid.bind(server.stores.user);
|
||||
vi.spyOn(server.stores.user, 'getByUuid').mockImplementation(
|
||||
async (...args: Parameters<typeof getByUuid>) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
order.push('account');
|
||||
return getByUuid(...args);
|
||||
},
|
||||
);
|
||||
const getEntryByPath = server.stores.fsEntry.getEntryByPath.bind(
|
||||
server.stores.fsEntry,
|
||||
);
|
||||
vi.spyOn(server.stores.fsEntry, 'getEntryByPath').mockImplementation(
|
||||
async (...args: Parameters<typeof getEntryByPath>) => {
|
||||
if (isProfilePath(args[0])) order.push('profile');
|
||||
return getEntryByPath(...args);
|
||||
},
|
||||
);
|
||||
|
||||
await expect(getOwn(user.uuid)).resolves.toEqual({
|
||||
picture: null,
|
||||
displayName: null,
|
||||
bio: 'hi',
|
||||
});
|
||||
expect(order).toEqual(['profile', 'account']);
|
||||
});
|
||||
|
||||
it('404s a caller with no account, whatever the profile read did', async () => {
|
||||
vi.spyOn(server.stores.fsEntry, 'getEntryByPath').mockRejectedValue(
|
||||
new Error('profile read failed'),
|
||||
);
|
||||
await expect(getOwn(uuidv4())).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces the account lookup failure before the profile read failure', async () => {
|
||||
vi.spyOn(server.stores.user, 'getByUuid').mockRejectedValue(
|
||||
new Error('account lookup failed'),
|
||||
);
|
||||
vi.spyOn(server.stores.fsEntry, 'getEntryByPath').mockRejectedValue(
|
||||
new Error('profile read failed'),
|
||||
);
|
||||
await expect(getOwn(uuidv4())).rejects.toThrow('account lookup failed');
|
||||
});
|
||||
|
||||
it('surfaces a failed profile read for an existing account', async () => {
|
||||
const user = await makeUser();
|
||||
vi.spyOn(server.stores.fsEntry, 'getEntryByPath').mockRejectedValue(
|
||||
new Error('profile read failed'),
|
||||
);
|
||||
await expect(getOwn(user.uuid)).rejects.toThrow('profile read failed');
|
||||
});
|
||||
});
|
||||
@@ -20,10 +20,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type {
|
||||
ProfileService,
|
||||
UserProfile,
|
||||
} from '../../services/profile/ProfileService.js';
|
||||
import type { ProfileService } from '../../services/profile/ProfileService.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
// Shape only; the store decides whether the account exists.
|
||||
@@ -59,21 +56,13 @@ export class ProfileController extends PuterController {
|
||||
const username = req.query.username;
|
||||
|
||||
let target;
|
||||
let ownProfile: PromiseSettledResult<UserProfile> | undefined;
|
||||
if (username === undefined) {
|
||||
if (!caller?.uuid) {
|
||||
throw new HttpError(401, 'Sign in, or pass a username', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
}
|
||||
// Read alongside the account; discarded if there's no account.
|
||||
const [found, profile] = await Promise.allSettled([
|
||||
this.stores.user.getByUuid(caller.uuid),
|
||||
service.getProfile({ uuid: caller.uuid }),
|
||||
]);
|
||||
if (found.status === 'rejected') throw found.reason;
|
||||
target = found.value;
|
||||
ownProfile = profile;
|
||||
target = await this.stores.user.getByUuid(caller.uuid);
|
||||
} else {
|
||||
if (
|
||||
typeof username !== 'string' ||
|
||||
@@ -91,11 +80,6 @@ export class ProfileController extends PuterController {
|
||||
if (!isOwner && !(await service.isPubliclyVisible(target))) {
|
||||
throw notFound();
|
||||
}
|
||||
if (ownProfile && isOwner) {
|
||||
if (ownProfile.status === 'rejected') throw ownProfile.reason;
|
||||
res.json(ownProfile.value);
|
||||
return;
|
||||
}
|
||||
res.json(await service.getProfile(target));
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { finished } from 'node:stream/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
afterAll,
|
||||
@@ -1531,34 +1530,25 @@ describe('PuterAIController videoProxy upstream handling', () => {
|
||||
} as unknown as Response);
|
||||
|
||||
const handler = captureGet('/puterai/video/proxy');
|
||||
// The body is piped, so the response has to be a real stream.
|
||||
const { res, captured } = makeRes();
|
||||
const sink: Buffer[] = [];
|
||||
const headers: Record<string, string> = {};
|
||||
const res = Object.assign(
|
||||
new Writable({
|
||||
write(chunk: Buffer, _enc, cb) {
|
||||
sink.push(Buffer.from(chunk));
|
||||
cb();
|
||||
},
|
||||
}),
|
||||
{
|
||||
setHeader: (k: string, v: string) => {
|
||||
headers[k] = v;
|
||||
},
|
||||
},
|
||||
) as unknown as Response;
|
||||
(res as unknown as Record<string, unknown>).write = (
|
||||
chunk: Buffer,
|
||||
) => {
|
||||
sink.push(Buffer.from(chunk));
|
||||
return true;
|
||||
};
|
||||
(res as unknown as Record<string, unknown>).emit = () => true;
|
||||
|
||||
await handler(makeReq({ query: signedQuery('vid1') }), res);
|
||||
await finished(res as unknown as Writable);
|
||||
await settleStream();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'vid1:download?alt=media&key=gemini-test-key',
|
||||
),
|
||||
{ signal: expect.any(AbortSignal) },
|
||||
);
|
||||
expect(headers['Content-Type']).toBe('video/mp4');
|
||||
expect(Buffer.concat(sink).toString('utf8')).toBe('video-bytes');
|
||||
expect(captured.headers['Content-Type']).toBe('video/mp4');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import { Context } from '../../core/context.js';
|
||||
import { abortOnDisconnect } from '../../core/http/abortOnDisconnect.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
import { pipeToResponse } from '../../core/http/pipeToResponse.js';
|
||||
import { computeNetworkFingerprint } from '../../core/http/middleware/rateLimit.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import {
|
||||
@@ -338,10 +337,8 @@ export class PuterAIController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
// A visitor who leaves stops the download, not just the reply.
|
||||
const upstream = await fetch(
|
||||
`${GEMINI_DOWNLOAD_BASE}/${fileId}:download?alt=media&key=${apiKey}`,
|
||||
{ signal: abortOnDisconnect(res) },
|
||||
);
|
||||
if (!upstream.ok) {
|
||||
res.status(upstream.status).send('Failed to fetch video');
|
||||
@@ -354,12 +351,9 @@ export class PuterAIController extends PuterController {
|
||||
res.status(500).send('Empty response body');
|
||||
return;
|
||||
}
|
||||
void pipeToResponse(
|
||||
Readable.fromWeb(
|
||||
upstream.body as unknown as import('node:stream/web').ReadableStream,
|
||||
),
|
||||
res,
|
||||
);
|
||||
Readable.fromWeb(
|
||||
upstream.body as unknown as import('node:stream/web').ReadableStream,
|
||||
).pipe(res);
|
||||
};
|
||||
|
||||
#listModels(driverKey: 'aiChat' | 'aiImage' | 'aiVideo') {
|
||||
@@ -545,8 +539,7 @@ export class PuterAIController extends PuterController {
|
||||
}
|
||||
} else if (ev.type === 'usage') {
|
||||
usageDetails = ev.usageDetails as
|
||||
| UsageDetails
|
||||
| undefined;
|
||||
UsageDetails | undefined;
|
||||
finishReason = ev.finish_reason as string | undefined;
|
||||
} else if (ev.type === 'error') {
|
||||
res.write(
|
||||
@@ -694,8 +687,7 @@ export class PuterAIController extends PuterController {
|
||||
sendChunk(ev.text);
|
||||
} else if (ev.type === 'usage') {
|
||||
usageDetails = ev.usageDetails as
|
||||
| UsageDetails
|
||||
| undefined;
|
||||
UsageDetails | undefined;
|
||||
finishReason = ev.finish_reason as string | undefined;
|
||||
} else if (ev.type === 'error') {
|
||||
res.write(
|
||||
@@ -749,8 +741,7 @@ export class PuterAIController extends PuterController {
|
||||
text: extractTextContent(
|
||||
(
|
||||
messageResult.message as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
Record<string, unknown> | undefined
|
||||
)?.content,
|
||||
),
|
||||
index: 0,
|
||||
|
||||
@@ -1,249 +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 crypto from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { allocateEphemeralPort, setupTestServer } from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
|
||||
/**
|
||||
* The video proxy over a real socket: the provider download is the only thing
|
||||
* stubbed, so what reaches the client and what a hang-up does upstream are the
|
||||
* server's own behavior.
|
||||
*/
|
||||
describe('video proxy over HTTP', () => {
|
||||
const DOWNLOAD_BASE = 'https://generativelanguage.googleapis.com/';
|
||||
const SECRET = 'video-proxy-test-secret';
|
||||
let server: PuterServer;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
port = await allocateEphemeralPort();
|
||||
server = await setupTestServer(
|
||||
{
|
||||
port,
|
||||
domain: 'puter.localhost',
|
||||
origin: `http://puter.localhost:${port}`,
|
||||
api_base_url: `http://api.puter.localhost:${port}`,
|
||||
url_signature_secret: SECRET,
|
||||
providers: {
|
||||
'gemini-video-generation': { apiKey: 'test-key' },
|
||||
},
|
||||
} as unknown as IConfig,
|
||||
{ listen: true },
|
||||
);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const signedPath = (fileId: string) => {
|
||||
const expires = String(Math.floor(Date.now() / 1000) + 60);
|
||||
const signature = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${fileId}/video-proxy/${SECRET}/${expires}`)
|
||||
.digest('hex');
|
||||
return `/puterai/video/proxy?${new URLSearchParams({
|
||||
fileId,
|
||||
expires,
|
||||
signature,
|
||||
provider: 'gemini',
|
||||
})}`;
|
||||
};
|
||||
|
||||
/** Serves `body` as the provider download and records what upstream sees. */
|
||||
const stubDownload = (body: ReadableStream<Uint8Array>) => {
|
||||
const seen: { signal?: AbortSignal } = {};
|
||||
const realFetch = globalThis.fetch;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
async (input, init) => {
|
||||
if (!String(input).startsWith(DOWNLOAD_BASE)) {
|
||||
return realFetch(input, init);
|
||||
}
|
||||
seen.signal = init?.signal ?? undefined;
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
});
|
||||
},
|
||||
);
|
||||
return seen;
|
||||
};
|
||||
|
||||
const get = (path: string) =>
|
||||
http.request({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path,
|
||||
headers: { host: `api.puter.localhost:${port}` },
|
||||
});
|
||||
|
||||
const waitFor = async (check: () => boolean) => {
|
||||
for (let i = 0; i < 100 && !check(); i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
return check();
|
||||
};
|
||||
|
||||
it('streams the provider download to the client byte for byte', async () => {
|
||||
const parts = ['video', '-', 'bytes'].map((s) =>
|
||||
new TextEncoder().encode(s),
|
||||
);
|
||||
stubDownload(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
for (const part of parts) controller.enqueue(part);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { status, contentType, body } = await new Promise<{
|
||||
status?: number;
|
||||
contentType?: string;
|
||||
body: string;
|
||||
}>((resolve, reject) => {
|
||||
const req = get(signedPath('vid-ok'));
|
||||
req.on('response', (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () =>
|
||||
resolve({
|
||||
status: res.statusCode,
|
||||
contentType: res.headers['content-type'],
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
}),
|
||||
);
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(contentType).toBe('video/mp4');
|
||||
expect(body).toBe('video-bytes');
|
||||
});
|
||||
|
||||
it('streams without a listener-leak warning on the response', async () => {
|
||||
stubDownload(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('video'));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const warnings: string[] = [];
|
||||
const onWarning = (warning: Error) => warnings.push(warning.name);
|
||||
process.on('warning', onWarning);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = get(signedPath('vid-listeners'));
|
||||
req.on('response', (res) => {
|
||||
res.on('data', () => {});
|
||||
res.on('end', resolve);
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
} finally {
|
||||
process.off('warning', onWarning);
|
||||
}
|
||||
expect(warnings).not.toContain('MaxListenersExceededWarning');
|
||||
});
|
||||
|
||||
it('stops the provider download when the client hangs up', async () => {
|
||||
let cancelled = false;
|
||||
const seen = stubDownload(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('first'));
|
||||
},
|
||||
// Never finishes on its own: only a cancel ends it.
|
||||
pull: () => new Promise(() => {}),
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = get(signedPath('vid-leave'));
|
||||
req.on('response', (res) => {
|
||||
res.once('data', () => {
|
||||
req.destroy();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
if (!req.destroyed) reject(err);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(await waitFor(() => cancelled)).toBe(true);
|
||||
expect(seen.signal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('cuts the reply off when the provider download breaks mid-stream', async () => {
|
||||
stubDownload(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('partial'));
|
||||
setTimeout(
|
||||
() => controller.error(new Error('upstream broke')),
|
||||
10,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const outcome = await new Promise<string>((resolve) => {
|
||||
const req = get(signedPath('vid-broken'));
|
||||
req.on('response', (res) => {
|
||||
res.on('data', () => {});
|
||||
res.on('end', () => resolve('ended'));
|
||||
res.on('error', () => resolve('aborted'));
|
||||
res.on('aborted', () => resolve('aborted'));
|
||||
});
|
||||
req.on('error', () => resolve('aborted'));
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(outcome).toBe('aborted');
|
||||
});
|
||||
});
|
||||
@@ -341,61 +341,6 @@ describe('share endpoints over HTTP', () => {
|
||||
expect(replayed.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops what a folder delegate re-shared once the item leaves the folder', async () => {
|
||||
const owner = await makeUser();
|
||||
const delegate = await makeUser();
|
||||
const holder = await makeUser();
|
||||
|
||||
const dir = (await (
|
||||
await post('/mkdir', owner.token, {
|
||||
path: `/${owner.username}/Proj`,
|
||||
})
|
||||
).json()) as { uid: string };
|
||||
await post('/touch', owner.token, {
|
||||
path: `/${owner.username}/Proj/plan.txt`,
|
||||
});
|
||||
const file = (await (
|
||||
await post('/stat', owner.token, {
|
||||
path: `/${owner.username}/Proj/plan.txt`,
|
||||
})
|
||||
).json()) as { uid: string };
|
||||
|
||||
await post('/share', owner.token, {
|
||||
recipients: [delegate.username],
|
||||
items: [{ uid: dir.uid }],
|
||||
mode: 'manage',
|
||||
});
|
||||
await post('/share', delegate.token, {
|
||||
recipients: [holder.username],
|
||||
items: [{ uid: file.uid }],
|
||||
mode: 'read',
|
||||
});
|
||||
const listedTo = async (token: string) =>
|
||||
(
|
||||
(await (
|
||||
await get('/share/shared-by-me', token, {})
|
||||
).json()) as { items: Array<{ uid_entry: string }> }
|
||||
).items.map((i) => i.uid_entry);
|
||||
expect(await listedTo(delegate.token)).toContain(file.uid);
|
||||
|
||||
const moved = await post('/move', owner.token, {
|
||||
source: file.uid,
|
||||
destination: `/${owner.username}`,
|
||||
});
|
||||
expect(moved.status).toBe(200);
|
||||
// A move doesn't bump readings; this stands in for the scan-cache TTL.
|
||||
const delegateRow = await env.server.stores.user.getByUsername(
|
||||
delegate.username,
|
||||
);
|
||||
await env.server.stores.permission.bumpCacheGeneration(
|
||||
`user:${delegateRow!.uuid}`,
|
||||
);
|
||||
|
||||
expect(await listedTo(delegate.token)).not.toContain(file.uid);
|
||||
// The holder's grant still stands, so its owner still sees it.
|
||||
expect(await listedTo(owner.token)).toContain(file.uid);
|
||||
});
|
||||
|
||||
// A caller who believes they filtered must not silently receive
|
||||
// everything: a duplicated param arrives as an array, and an empty
|
||||
// string names nothing.
|
||||
|
||||
@@ -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,559 @@
|
||||
/*
|
||||
* 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'),
|
||||
});
|
||||
// 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,13 +1,11 @@
|
||||
// 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';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { hash as bcryptHash } from 'bcrypt';
|
||||
import { TOTP } from 'otpauth';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { RouteDescriptor } from '../../core/http/types.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
@@ -16,7 +14,6 @@ import { makeActor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
|
||||
import type { WebDAVController } from './WebDAVController.js';
|
||||
|
||||
let server: PuterServer;
|
||||
@@ -673,92 +670,6 @@ describe('WebDAVController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Basic-auth account parity with /login', () => {
|
||||
const makeBasicUser = async (
|
||||
prefix: string,
|
||||
fields: Record<string, unknown>,
|
||||
) => {
|
||||
const username = `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: await bcryptHash('correct-horse', 4),
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await server.stores.user.update(created.id, fields);
|
||||
return { id: created.id, username };
|
||||
};
|
||||
|
||||
const propfindAs = async (username: string, password: string) => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
headers: { authorization: basicAuth(username, password) },
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
return captured.statusCode;
|
||||
};
|
||||
|
||||
const nowSeconds = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
it('rejects an expired temporary password with 401', async () => {
|
||||
const { username } = await makeBasicUser('webdav-temp-expired', {
|
||||
temp_password_expires_at: nowSeconds() - 60,
|
||||
});
|
||||
expect(await propfindAs(username, 'correct-horse')).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a seat that has not replaced its temporary password with 403', async () => {
|
||||
const { username } = await makeBasicUser('webdav-temp-live', {
|
||||
temp_password_expires_at: nowSeconds() + 3600,
|
||||
requires_password_change: 1,
|
||||
});
|
||||
expect(await propfindAs(username, 'correct-horse')).toBe(403);
|
||||
});
|
||||
|
||||
it('lets a live temporary password through once the change flag is clear', async () => {
|
||||
const { username } = await makeBasicUser('webdav-temp-ok', {
|
||||
temp_password_expires_at: nowSeconds() + 3600,
|
||||
requires_password_change: 0,
|
||||
});
|
||||
const status = await propfindAs(username, 'correct-horse');
|
||||
expect([401, 403]).not.toContain(status);
|
||||
});
|
||||
|
||||
it('lets a 2FA member of a team that requires 2FA through', async () => {
|
||||
const { id, username } = await makeBasicUser('webdav-2fa', {});
|
||||
const secret = otpCreateSecret(username).secret;
|
||||
await server.stores.user.update(id, {
|
||||
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');
|
||||
try {
|
||||
const code = new TOTP({
|
||||
issuer: 'puter.com',
|
||||
label: username,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
secret,
|
||||
}).generate();
|
||||
const status = await propfindAs(
|
||||
username,
|
||||
`correct-horse${code}`,
|
||||
);
|
||||
expect([401, 403]).not.toContain(status);
|
||||
} finally {
|
||||
seat.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported methods', () => {
|
||||
it('returns 405 for unknown HTTP methods', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
@@ -1007,7 +918,7 @@ describe('WebDAVController', () => {
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('refuses a delete outside the caller’s tree as not found', async () => {
|
||||
it('rejects delete when ACL denies write access', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
@@ -1024,7 +935,7 @@ describe('WebDAVController', () => {
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1594,7 +1505,7 @@ describe('WebDAVController verbs', () => {
|
||||
expect(captured.body).toBe('Cannot GET a directory');
|
||||
});
|
||||
|
||||
it("hides another user's file as not found", async () => {
|
||||
it("denies reading another user's file with 403", async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
const path = `/${owner.username}/Documents/private.txt`;
|
||||
@@ -1605,8 +1516,8 @@ describe('WebDAVController verbs', () => {
|
||||
path,
|
||||
actor: stranger.actor,
|
||||
});
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.body).toBe('Not Found');
|
||||
expect(captured.statusCode).toBe(403);
|
||||
expect(captured.body).toBe('Permission denied');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1894,18 +1805,6 @@ describe('WebDAVController verbs', () => {
|
||||
expect(captured.statusCode).toBe(207);
|
||||
expect(captured.body as string).toContain('HTTP/1.1 200 OK');
|
||||
});
|
||||
|
||||
it("refuses a property update on another user's path", async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const captured = await dispatch({
|
||||
method: 'PROPPATCH',
|
||||
path: `/${owner.username}/Documents`,
|
||||
actor: other.actor,
|
||||
});
|
||||
// Out of sight, so refused like a path with nothing at it.
|
||||
expect(captured.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MKCOL', () => {
|
||||
@@ -1938,8 +1837,7 @@ describe('WebDAVController verbs', () => {
|
||||
path: `/${owner.username}/Documents/intruder`,
|
||||
actor: stranger.actor,
|
||||
});
|
||||
// The parent is out of sight, so the refusal doesn't confirm it.
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2150,7 +2048,7 @@ describe('WebDAVController verbs', () => {
|
||||
},
|
||||
});
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.body).toBe('Not Found');
|
||||
expect(captured.body).toBe('Source not found');
|
||||
});
|
||||
|
||||
it('returns 409 when the destination parent is not a directory', async () => {
|
||||
@@ -2276,7 +2174,7 @@ describe('WebDAVController verbs', () => {
|
||||
},
|
||||
});
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.body).toBe('Not Found');
|
||||
expect(captured.body).toBe('Source not found');
|
||||
});
|
||||
|
||||
it("denies moving into another user's home", async () => {
|
||||
@@ -2294,7 +2192,7 @@ describe('WebDAVController verbs', () => {
|
||||
host: 'dav.puter.localhost',
|
||||
},
|
||||
});
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2465,7 +2363,6 @@ describe('WebDAVController verbs', () => {
|
||||
const shareFolder = async (
|
||||
owner: Awaited<ReturnType<typeof makeUser>>,
|
||||
to: Awaited<ReturnType<typeof makeUser>>,
|
||||
mode: 'read' | 'write' = 'read',
|
||||
) => {
|
||||
const dirUuid = uuidv4();
|
||||
const dirPath = `/${owner.username}/Documents/Secrets`;
|
||||
@@ -2503,34 +2400,32 @@ describe('WebDAVController verbs', () => {
|
||||
server.services.share.share(owner.actor as never, {
|
||||
uid: dirUuid,
|
||||
recipient: { username: to.username! },
|
||||
mode,
|
||||
mode: 'read',
|
||||
}),
|
||||
);
|
||||
return { dirUuid, dirPath };
|
||||
};
|
||||
|
||||
/**
|
||||
* A request under the ALS wrap a real request gets, pre-auth actor
|
||||
* PROPFIND under the ALS wrap a real request gets, pre-auth actor
|
||||
* empty.
|
||||
*/
|
||||
const davRequest = async (
|
||||
method: string,
|
||||
path: string,
|
||||
actor: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
) => {
|
||||
const propfind = async (path: string, actor: unknown, depth = '1') => {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor: undefined }, () =>
|
||||
dispatchMiddleware(
|
||||
makeReq({ method, path, headers, actor }),
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
path,
|
||||
headers: { depth },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
),
|
||||
);
|
||||
return captured;
|
||||
};
|
||||
const propfind = (path: string, actor: unknown, depth = '1') =>
|
||||
davRequest('PROPFIND', path, actor, { depth });
|
||||
|
||||
// The DAV controller authenticates in-controller, after the request
|
||||
// context snapshotted an empty `req.actor` — so it has to place the
|
||||
@@ -2550,40 +2445,6 @@ describe('WebDAVController verbs', () => {
|
||||
expect(xml).not.toContain('/Documents/Secrets');
|
||||
});
|
||||
|
||||
it('refuses PROPPATCH on a share the recipient can only read', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const { dirUuid } = await shareFolder(owner, recipient);
|
||||
|
||||
const masked = `/${owner.username}/${dirUuid}/Secrets`;
|
||||
const captured = await davRequest(
|
||||
'PROPPATCH',
|
||||
masked,
|
||||
recipient.actor,
|
||||
);
|
||||
|
||||
expect(captured.statusCode).toBe(403);
|
||||
expect(String(captured.body)).not.toContain('/Documents/Secrets');
|
||||
});
|
||||
|
||||
it('answers PROPPATCH on a writable share under its masked path', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const { dirUuid } = await shareFolder(owner, recipient, 'write');
|
||||
|
||||
const masked = `/${owner.username}/${dirUuid}/Secrets`;
|
||||
const captured = await davRequest(
|
||||
'PROPPATCH',
|
||||
masked,
|
||||
recipient.actor,
|
||||
);
|
||||
|
||||
expect(captured.statusCode).toBe(207);
|
||||
const xml = String(captured.body);
|
||||
expect(xml).toContain(`<D:href>${masked}</D:href>`);
|
||||
expect(xml).not.toContain('/Documents/Secrets');
|
||||
});
|
||||
|
||||
it('lists each sharer as a collection under the root', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
@@ -2630,7 +2491,7 @@ describe('WebDAVController verbs', () => {
|
||||
expect(xml).not.toContain('Documents');
|
||||
});
|
||||
|
||||
it('keeps a sharer’s collection out of sight for a caller with no share', async () => {
|
||||
it('keeps a sharer’s collection a 403 for a caller with no share', async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
await shareFolder(owner, await makeUser());
|
||||
@@ -2639,8 +2500,7 @@ describe('WebDAVController verbs', () => {
|
||||
`/${owner.username}`,
|
||||
stranger.actor,
|
||||
);
|
||||
// The same 404 as a name nobody holds, so it confirms no account.
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2810,9 +2670,9 @@ describe('WebDAVController verbs', () => {
|
||||
|
||||
const root = await propfind('/', holder.actor);
|
||||
expect(String(root.body)).not.toContain(`/${owner.username}/`);
|
||||
// And the level it used to stand in for is out of sight again.
|
||||
// And the level it used to stand in for is a plain 403 again.
|
||||
const owned = await propfind(`/${owner.username}`, holder.actor);
|
||||
expect(owned.statusCode).toBe(404);
|
||||
expect(owned.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses writes and reads aimed at the invented levels', async () => {
|
||||
@@ -2861,257 +2721,4 @@ describe('WebDAVController verbs', () => {
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// Every check runs on the path the entry is stored under, and a refusal of
|
||||
// something the caller can't see reads exactly like a path with nothing
|
||||
// at it.
|
||||
describe('authorization against the stored entry', () => {
|
||||
it('answers a stranger alike for a missing path and a private file', async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
await putFile(
|
||||
owner.actor,
|
||||
`/${owner.username}/Documents/private.txt`,
|
||||
'secret',
|
||||
);
|
||||
|
||||
const answers = [];
|
||||
for (const [method, path] of [
|
||||
['GET', `/${owner.username}/Documents/private.txt`],
|
||||
['GET', `/${owner.username}/Documents/missing.txt`],
|
||||
// A directory too: refused before the "is a directory" 400.
|
||||
['GET', `/${owner.username}/Documents`],
|
||||
['PROPFIND', `/${owner.username}`],
|
||||
['PROPFIND', `/no-such-user-${uuidv4()}`],
|
||||
['DELETE', `/${owner.username}/Documents/private.txt`],
|
||||
['DELETE', `/${owner.username}/Documents/missing.txt`],
|
||||
] as const) {
|
||||
const captured = await dispatch({
|
||||
method,
|
||||
path,
|
||||
actor: stranger.actor,
|
||||
});
|
||||
answers.push([captured.statusCode, captured.body]);
|
||||
}
|
||||
for (const answer of answers) {
|
||||
expect(answer).toEqual([404, 'Not Found']);
|
||||
}
|
||||
});
|
||||
|
||||
// MySQL collates `fsentries.path` case-insensitively, so a request for
|
||||
// `/<owner>/Public/...` can resolve a private `/<owner>/public/...`.
|
||||
// The test databases compare exactly, so the lookup is made to answer
|
||||
// the way MySQL would; everything past it — the ancestor chain, the
|
||||
// ACL — runs for real.
|
||||
it('does not pass a case variant of Public off as the public folder', async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
await server.stores.user.update(owner.userId, {
|
||||
email_confirmed: 1,
|
||||
});
|
||||
const privateDir = `/${owner.username}/public`;
|
||||
const mkcol = await dispatch({
|
||||
method: 'MKCOL',
|
||||
path: privateDir,
|
||||
actor: owner.actor,
|
||||
});
|
||||
expect(mkcol.statusCode).toBe(201);
|
||||
await putFile(owner.actor, `${privateDir}/secret.txt`, 'secret');
|
||||
await putFile(
|
||||
owner.actor,
|
||||
`/${owner.username}/Public/open.txt`,
|
||||
'open',
|
||||
);
|
||||
|
||||
const acl = server.services.acl as unknown as {
|
||||
config: { enable_public_folders?: boolean };
|
||||
};
|
||||
const wasEnabled = acl.config.enable_public_folders;
|
||||
acl.config.enable_public_folders = true;
|
||||
try {
|
||||
// The real Public folder is open to the stranger.
|
||||
const open = await dispatch({
|
||||
method: 'GET',
|
||||
path: `/${owner.username}/Public/open.txt`,
|
||||
actor: stranger.actor,
|
||||
});
|
||||
expect(open.statusCode).toBe(200);
|
||||
|
||||
const store = server.stores.fsEntry;
|
||||
const realLookup = store.getEntryByPath.bind(store);
|
||||
const asked = `/${owner.username}/Public`;
|
||||
vi.spyOn(store, 'getEntryByPath').mockImplementation(
|
||||
(path: string) =>
|
||||
realLookup(
|
||||
path === asked || path.startsWith(`${asked}/`)
|
||||
? privateDir + path.slice(asked.length)
|
||||
: path,
|
||||
),
|
||||
);
|
||||
|
||||
const { res, captured } = makeStreamRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'GET',
|
||||
path: `${asked}/secret.txt`,
|
||||
actor: stranger.actor,
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.text()).toBe('');
|
||||
|
||||
const listing = await dispatch({
|
||||
method: 'PROPFIND',
|
||||
path: asked,
|
||||
actor: stranger.actor,
|
||||
});
|
||||
expect(listing.statusCode).toBe(404);
|
||||
expect(String(listing.body)).not.toContain('secret.txt');
|
||||
|
||||
const copied = await dispatch({
|
||||
method: 'COPY',
|
||||
path: `${asked}/secret.txt`,
|
||||
actor: stranger.actor,
|
||||
headers: {
|
||||
destination: `/${stranger.username}/Documents/loot.txt`,
|
||||
host: 'dav.puter.localhost',
|
||||
},
|
||||
});
|
||||
expect(copied.statusCode).toBe(404);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
acl.config.enable_public_folders = wasEnabled;
|
||||
}
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(
|
||||
`/${stranger.username}/Documents/loot.txt`,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// A scoped token writes over DAV wherever its grant reaches, and nowhere
|
||||
// else — the same reach it has over the `/fs` write routes.
|
||||
describe('scoped access tokens', () => {
|
||||
const mintFor = async (userId: number, permission: string) => {
|
||||
const user = (await server.stores.user.getById(userId))!;
|
||||
return server.services.auth.createAccessToken(makeActor({ user }), [
|
||||
[permission],
|
||||
]);
|
||||
};
|
||||
// Own address, so the per-IP auth failure bucket can't refuse these.
|
||||
const asToken = (token: string, init: Parameters<typeof dispatch>[0]) =>
|
||||
dispatch({
|
||||
...init,
|
||||
ip: '203.0.113.58',
|
||||
headers: {
|
||||
...init.headers,
|
||||
authorization: basicAuth('-token', token),
|
||||
},
|
||||
});
|
||||
|
||||
it('writes inside its grant and is refused outside it', async () => {
|
||||
const { userId, username, actor } = await makeUser();
|
||||
const documents = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
))!;
|
||||
const token = await mintFor(userId, `fs:${documents.uuid}:write`);
|
||||
const inside = `/${username}/Documents/tok`;
|
||||
|
||||
expect(
|
||||
(await asToken(token, { method: 'MKCOL', path: inside }))
|
||||
.statusCode,
|
||||
).toBe(201);
|
||||
expect(
|
||||
(
|
||||
await asToken(token, {
|
||||
method: 'PUT',
|
||||
path: `${inside}/a.txt`,
|
||||
headers: { 'content-length': '3' },
|
||||
content: 'abc',
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(201);
|
||||
expect(
|
||||
(
|
||||
await asToken(token, {
|
||||
method: 'MOVE',
|
||||
path: `${inside}/a.txt`,
|
||||
headers: {
|
||||
destination: `${inside}/b.txt`,
|
||||
host: 'dav.puter.localhost',
|
||||
},
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(201);
|
||||
expect(
|
||||
(
|
||||
await asToken(token, {
|
||||
method: 'DELETE',
|
||||
path: `${inside}/b.txt`,
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(204);
|
||||
|
||||
await putFile(actor, `/${username}/Desktop/kept.txt`, 'kept');
|
||||
for (const init of [
|
||||
{ method: 'MKCOL', path: `/${username}/Desktop/intruder` },
|
||||
{
|
||||
method: 'PUT',
|
||||
path: `/${username}/Desktop/kept.txt`,
|
||||
headers: { 'content-length': '1' },
|
||||
content: 'x',
|
||||
},
|
||||
{ method: 'DELETE', path: `/${username}/Desktop/kept.txt` },
|
||||
{
|
||||
method: 'COPY',
|
||||
path: `/${username}/Desktop/kept.txt`,
|
||||
headers: {
|
||||
destination: `${inside}/kept.txt`,
|
||||
host: 'dav.puter.localhost',
|
||||
},
|
||||
},
|
||||
{ method: 'DELETE', path: `/${username}` },
|
||||
]) {
|
||||
const captured = await asToken(token, init);
|
||||
// Outside the grant is out of sight, so it reads as missing.
|
||||
expect(captured.statusCode, init.method).toBe(404);
|
||||
}
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Desktop/intruder`,
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Desktop/kept.txt`,
|
||||
),
|
||||
).toMatchObject({ size: 4 });
|
||||
});
|
||||
|
||||
it('refuses every write to a read-only token', async () => {
|
||||
const { userId, username } = await makeUser();
|
||||
const documents = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
))!;
|
||||
const token = await mintFor(userId, `fs:${documents.uuid}:read`);
|
||||
|
||||
for (const init of [
|
||||
{ method: 'MKCOL', path: `/${username}/Documents/ro` },
|
||||
{
|
||||
method: 'PUT',
|
||||
path: `/${username}/Documents/ro.txt`,
|
||||
headers: { 'content-length': '1' },
|
||||
content: 'x',
|
||||
},
|
||||
{ method: 'DELETE', path: `/${username}/Documents` },
|
||||
]) {
|
||||
const captured = await asToken(token, init);
|
||||
expect(captured.statusCode, init.method).toBe(403);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,12 +17,11 @@
|
||||
* 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';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
assertNotSuspended,
|
||||
@@ -45,13 +44,12 @@ import {
|
||||
Put,
|
||||
Unlock,
|
||||
} from '../../core/http/decorators.js';
|
||||
import { PREFLIGHT_MAX_AGE, type RouteOptions } from '../../core/http/types.js';
|
||||
import type { RouteOptions } from '../../core/http/types.js';
|
||||
import { verify as verifyOtp } from '../../services/auth/OTPUtil.js';
|
||||
import { expandTildePath } from '../../services/fs/resolveNode.js';
|
||||
import { rootShowsHome } from '../../services/fs/rootListing.js';
|
||||
import {
|
||||
maskEntryPath,
|
||||
maskPathForRequest,
|
||||
parseMaskedSharePath,
|
||||
resolveSharePath,
|
||||
} from '../../services/fs/sharePathMask.js';
|
||||
@@ -83,7 +81,6 @@ import {
|
||||
peekRateLimit,
|
||||
} from '../../core/http/middleware/rateLimit.js';
|
||||
import { assertActorHasCredits } from '../../services/metering/enforcement.js';
|
||||
import { isTemporaryPasswordExpired } from '../../util/temporaryPassword.js';
|
||||
import type { ResolvedShare } from '../../services/share/ShareService.js';
|
||||
|
||||
const DAV_HEADERS = {
|
||||
@@ -119,6 +116,9 @@ const DAV_ROUTE_OPTIONS: RouteOptions = {
|
||||
concurrent: DAV_CONCURRENT,
|
||||
};
|
||||
|
||||
/** How long a browser may cache a DAV preflight. */
|
||||
const PREFLIGHT_MAX_AGE = '86400';
|
||||
|
||||
/** How many shared items the virtual share collections list. */
|
||||
const SHARE_LISTING_CAP = 200;
|
||||
|
||||
@@ -289,7 +289,7 @@ export class WebDAVController extends PuterController {
|
||||
// filesystem over the `dav` subdomain, bypassing the gates.
|
||||
assertNotSuspended(actor.user);
|
||||
assertVerifiedAccount(actor.user);
|
||||
await assertTeam2fa(actor.user, this.stores.team 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
|
||||
@@ -475,19 +475,28 @@ export class WebDAVController extends PuterController {
|
||||
return null;
|
||||
}
|
||||
|
||||
// An administrator-issued temporary password that was never replaced
|
||||
// dies here as it does at /login, rather than living on over DAV.
|
||||
if (isTemporaryPasswordExpired(user)) {
|
||||
res.status(401)
|
||||
.set('WWW-Authenticate', 'Basic realm="WebDAV"')
|
||||
.send('Temporary password expired');
|
||||
return null;
|
||||
}
|
||||
|
||||
// The same row→actor mapping the cookie / `-token` paths use, so the
|
||||
// gates in #context see every flag (requires_password_change,
|
||||
// otp_enabled, the verification flags) rather than a hand-picked few.
|
||||
return this.services.auth.buildPasswordActor(user);
|
||||
// Build a session-less actor for the user
|
||||
return makeActor({
|
||||
user: {
|
||||
id: user.id,
|
||||
uuid: user.uuid,
|
||||
username: user.username,
|
||||
email: user.email ?? null,
|
||||
suspended: user.suspended ?? false,
|
||||
email_confirmed: user.email_confirmed ?? false,
|
||||
requires_email_confirmation:
|
||||
user.requires_email_confirmation ?? false,
|
||||
// Carry the signup-time verification flags so the
|
||||
// assertVerifiedAccount() gate in #dispatch can see them on
|
||||
// the Basic-auth path too (the cookie / `-token` paths get
|
||||
// them from AuthService#actorUserFromRow). Omitting these is
|
||||
// what let phone/card-gated accounts through WebDAV.
|
||||
requires_phone_verification:
|
||||
user.requires_phone_verification ?? false,
|
||||
requires_card_verification:
|
||||
user.requires_card_verification ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// -- OPTIONS ------------------------------------------------------
|
||||
@@ -513,15 +522,15 @@ export class WebDAVController extends PuterController {
|
||||
headOnly: boolean,
|
||||
): Promise<void> {
|
||||
const entry = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (!entry) throw davNotFound();
|
||||
// Authorized before the directory check, so a caller who can't see
|
||||
// the entry can't learn that it is one.
|
||||
await this.#assertAccess(actor, entry.path, 'read');
|
||||
if (!entry)
|
||||
throw new HttpError(404, 'Not Found', { legacyCode: 'not_found' });
|
||||
if (entry.isDir)
|
||||
throw new HttpError(400, 'Cannot GET a directory', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
await this.#assertRead(actor, davPath);
|
||||
|
||||
const modified = entry.modified ?? entry.created ?? 0;
|
||||
const etag = entryEtag(entry.uuid, modified);
|
||||
const size = entry.size ?? 0;
|
||||
@@ -567,7 +576,7 @@ export class WebDAVController extends PuterController {
|
||||
const depth = req.headers.depth ?? '1';
|
||||
|
||||
if (davPath === '/') {
|
||||
await this.#assertAccess(actor, davPath, 'read');
|
||||
await this.#assertRead(actor, davPath);
|
||||
this.#sendMultistatus(res, await this.#rootPropfind(actor, depth));
|
||||
return;
|
||||
}
|
||||
@@ -589,9 +598,11 @@ export class WebDAVController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry) throw davNotFound();
|
||||
if (!entry) {
|
||||
throw new HttpError(404, 'Not Found', { legacyCode: 'not_found' });
|
||||
}
|
||||
|
||||
await this.#assertAccess(actor, entry.path, 'read');
|
||||
await this.#assertRead(actor, davPath);
|
||||
|
||||
// `davPath` is the resolved real path; the href has to be the masked
|
||||
// one, like every child below it, or the self-entry names the owner's
|
||||
@@ -693,7 +704,7 @@ export class WebDAVController extends PuterController {
|
||||
if (!entry?.isDir) return null;
|
||||
// An owner who shared their whole home directory has a real listing
|
||||
// here, and it beats the virtual one.
|
||||
if (await this.#canAccess(actor, entry.path, 'read')) return null;
|
||||
if (await this.#canRead(actor, davPath)) return null;
|
||||
|
||||
const shares = (await this.#sharedWithMe(actor)).filter(
|
||||
(share) => share.owner?.username === owner,
|
||||
@@ -745,20 +756,17 @@ export class WebDAVController extends PuterController {
|
||||
|
||||
async #proppatch(
|
||||
res: Response,
|
||||
{ actor, davPath, lockToken }: DavContext,
|
||||
{ davPath, lockToken }: DavContext,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!(await hasWritePermission(this.clients.redis, davPath, lockToken))
|
||||
) {
|
||||
throw new HttpError(423, 'Locked', { legacyCode: 'conflict' });
|
||||
}
|
||||
const target = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (target) await this.#assertAccess(actor, target.path, 'write');
|
||||
else await this.#assertCreate(actor, davPath);
|
||||
res.status(207)
|
||||
.set({ 'Content-Type': 'application/xml; charset=utf-8' })
|
||||
.send(
|
||||
`<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:"><D:response><D:href>${escapeXml(encodeURI(maskPathForRequest(davPath)))}</D:href><D:propstat><D:prop/><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response></D:multistatus>`,
|
||||
`<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:"><D:response><D:href>${escapeXml(encodeURI(davPath))}</D:href><D:propstat><D:prop/><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response></D:multistatus>`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -787,7 +795,8 @@ export class WebDAVController extends PuterController {
|
||||
throw new HttpError(423, 'Locked', { legacyCode: 'conflict' });
|
||||
}
|
||||
const userId = actor.user!.id as number;
|
||||
await this.#assertCreate(actor, davPath);
|
||||
const parentPath = pathPosix.dirname(davPath);
|
||||
await this.#assertWrite(actor, parentPath);
|
||||
|
||||
const existing = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (existing)
|
||||
@@ -823,11 +832,8 @@ export class WebDAVController extends PuterController {
|
||||
}
|
||||
|
||||
const userId = actor.user!.id as number;
|
||||
// Overwriting takes write on what is there, as `/fs/write` does;
|
||||
// creating takes write on the parent.
|
||||
const existing = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (existing) await this.#assertAccess(actor, existing.path, 'write');
|
||||
else await this.#assertCreate(actor, davPath);
|
||||
const parentPath = pathPosix.dirname(davPath);
|
||||
await this.#assertWrite(actor, parentPath);
|
||||
|
||||
const contentLength = Number(
|
||||
req.headers['content-length'] ??
|
||||
@@ -839,6 +845,9 @@ export class WebDAVController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
// Check if overwrite
|
||||
const existing = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
|
||||
// Expect: 100-continue
|
||||
if (req.headers.expect === '100-continue') {
|
||||
(req.socket as { write?: (s: string) => void }).write?.(
|
||||
@@ -883,9 +892,11 @@ export class WebDAVController extends PuterController {
|
||||
throw new HttpError(423, 'Locked', { legacyCode: 'conflict' });
|
||||
}
|
||||
const userId = actor.user!.id as number;
|
||||
await this.#assertWrite(actor, davPath);
|
||||
|
||||
const entry = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (!entry) throw davNotFound();
|
||||
await this.#assertAccess(actor, entry.path, 'write');
|
||||
if (!entry)
|
||||
throw new HttpError(404, 'Not Found', { legacyCode: 'not_found' });
|
||||
|
||||
await this.services.fs.remove(userId, { entry, recursive: true });
|
||||
this.#emitGuiEvent('outer.gui.item.removed', entry);
|
||||
@@ -907,10 +918,14 @@ export class WebDAVController extends PuterController {
|
||||
}
|
||||
|
||||
const userId = actor.user!.id as number;
|
||||
await this.#assertRead(actor, davPath);
|
||||
await this.#assertWrite(actor, pathPosix.dirname(destPath));
|
||||
|
||||
const source = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (!source) throw davNotFound();
|
||||
await this.#assertAccess(actor, source.path, 'read');
|
||||
await this.#assertCreate(actor, destPath);
|
||||
if (!source)
|
||||
throw new HttpError(404, 'Source not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
|
||||
const overwrite = req.headers.overwrite !== 'F';
|
||||
const destExists = await this.stores.fsEntry.getEntryByPath(destPath);
|
||||
@@ -955,10 +970,14 @@ export class WebDAVController extends PuterController {
|
||||
throw new HttpError(423, 'Locked', { legacyCode: 'conflict' });
|
||||
|
||||
const userId = actor.user!.id as number;
|
||||
await this.#assertWrite(actor, davPath);
|
||||
await this.#assertWrite(actor, pathPosix.dirname(destPath));
|
||||
|
||||
const source = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (!source) throw davNotFound();
|
||||
await this.#assertAccess(actor, source.path, 'write');
|
||||
await this.#assertCreate(actor, destPath);
|
||||
if (!source)
|
||||
throw new HttpError(404, 'Source not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
|
||||
const overwrite = req.headers.overwrite !== 'F';
|
||||
const destExists = await this.stores.fsEntry.getEntryByPath(destPath);
|
||||
@@ -1003,11 +1022,8 @@ export class WebDAVController extends PuterController {
|
||||
|
||||
// ACL must succeed before any lock state is touched — otherwise
|
||||
// an authenticated user could lock paths they don't own (e.g. `/`)
|
||||
// and block writes for everyone else. A path with nothing at it yet
|
||||
// is a lock-null resource, locked ahead of its create.
|
||||
const target = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (target) await this.#assertAccess(actor, target.path, 'write');
|
||||
else await this.#assertCreate(actor, davPath);
|
||||
// and block writes for everyone else.
|
||||
await this.#assertWrite(actor, davPath);
|
||||
|
||||
const timeoutSeconds = parseLockTimeout(
|
||||
req.headers.timeout as string | undefined,
|
||||
@@ -1140,46 +1156,25 @@ export class WebDAVController extends PuterController {
|
||||
|
||||
// -- ACL helpers -------------------------------------------------
|
||||
|
||||
// Checks take the path an entry is stored under, never the request's
|
||||
// spelling of it: the path lookup can match a differently-cased row (MySQL
|
||||
// collates `fsentries.path` case-insensitively), while the ACL's path rules
|
||||
// — home, Public — compare exactly. Only a create, whose target has no row
|
||||
// yet, falls back to a requested path.
|
||||
|
||||
async #canAccess(
|
||||
actor: Actor,
|
||||
path: string,
|
||||
mode: 'read' | 'write',
|
||||
): Promise<boolean> {
|
||||
async #canRead(actor: Actor, path: string): Promise<boolean> {
|
||||
const acl = this.services.acl;
|
||||
return await acl.check(actor, acl.fsDescriptor(path), mode);
|
||||
return await acl.check(actor, acl.fsDescriptor(path), 'read');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refused the way `/fs` refuses: a caller who can't even see the target
|
||||
* gets the same 404 as for a path with nothing at it, so a refusal never
|
||||
* confirms that something exists.
|
||||
*/
|
||||
async #assertAccess(
|
||||
actor: Actor,
|
||||
path: string,
|
||||
mode: 'read' | 'write',
|
||||
): Promise<void> {
|
||||
const acl = this.services.acl;
|
||||
const descriptor = acl.fsDescriptor(path);
|
||||
if (await acl.check(actor, descriptor, mode)) return;
|
||||
const safe = await acl.getSafeAclError(actor, descriptor, mode);
|
||||
if (safe.status === 404) throw davNotFound();
|
||||
throw new HttpError(403, 'Permission denied', {
|
||||
legacyCode: 'permission_denied',
|
||||
});
|
||||
async #assertRead(actor: Actor, path: string): Promise<void> {
|
||||
if (!(await this.#canRead(actor, path)))
|
||||
throw new HttpError(403, 'Permission denied', {
|
||||
legacyCode: 'permission_denied',
|
||||
});
|
||||
}
|
||||
|
||||
/** Write on the parent of a path about to be created at `targetPath`. */
|
||||
async #assertCreate(actor: Actor, targetPath: string): Promise<void> {
|
||||
const parentPath = pathPosix.dirname(targetPath);
|
||||
const parent = await this.stores.fsEntry.getEntryByPath(parentPath);
|
||||
await this.#assertAccess(actor, parent?.path ?? parentPath, 'write');
|
||||
async #assertWrite(actor: Actor, path: string): Promise<void> {
|
||||
const acl = this.services.acl;
|
||||
const ok = await acl.check(actor, acl.fsDescriptor(path), 'write');
|
||||
if (!ok)
|
||||
throw new HttpError(403, 'Permission denied', {
|
||||
legacyCode: 'permission_denied',
|
||||
});
|
||||
}
|
||||
|
||||
// -- Event emission ----------------------------------------------
|
||||
@@ -1273,11 +1268,6 @@ function sendDavError(res: Response, err: unknown): void {
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
|
||||
/** Nothing there — or nothing the caller may know is there; they read alike. */
|
||||
function davNotFound(): HttpError {
|
||||
return new HttpError(404, 'Not Found', { legacyCode: 'not_found' });
|
||||
}
|
||||
|
||||
// -- XML helpers ------------------------------------------------------
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Request, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { createTestUser, setupTestServer } from '../../testUtil.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import type { WispController } from './WispController.js';
|
||||
|
||||
let server: PuterServer;
|
||||
@@ -147,114 +147,26 @@ describe('WispController', () => {
|
||||
});
|
||||
|
||||
describe('verify', () => {
|
||||
const relayTokenFor = async (actor: unknown) => {
|
||||
const { res, captured } = makeRes();
|
||||
await createHandler(makeReq({ actor }), res);
|
||||
return (captured.body as { token: string }).token;
|
||||
};
|
||||
|
||||
const verifyStatus = async (token: string) => {
|
||||
const { res, captured } = makeRes();
|
||||
try {
|
||||
await verifyHandler(makeReq({ body: { token } }), res);
|
||||
return captured.statusCode;
|
||||
} catch (e) {
|
||||
return (e as { statusCode: number }).statusCode;
|
||||
}
|
||||
};
|
||||
|
||||
const freshUser = async () => {
|
||||
const username = `wisp_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const creds = await createTestUser(server, {
|
||||
username,
|
||||
password: 'wisp-test-password',
|
||||
});
|
||||
const row = (await server.stores.user.getByUsername(username))!;
|
||||
const actorFor = async (token: string) =>
|
||||
(await server.services.auth.authenticateFromToken(token))!;
|
||||
return {
|
||||
row,
|
||||
sessionActor: await actorFor(creds.token),
|
||||
apiTokenActor: await actorFor(creds.apiToken),
|
||||
apiToken: creds.apiToken,
|
||||
};
|
||||
};
|
||||
|
||||
it('verifies a token minted by a live session', async () => {
|
||||
const { sessionActor } = await freshUser();
|
||||
const token = await relayTokenFor(sessionActor);
|
||||
it('verifies a valid authenticated-user token', async () => {
|
||||
const { res: createRes, captured: createCaptured } = makeRes();
|
||||
await createHandler(
|
||||
makeReq({
|
||||
actor: {
|
||||
user: {
|
||||
uuid: '00000000-0000-0000-0000-000000000001',
|
||||
},
|
||||
},
|
||||
}),
|
||||
createRes,
|
||||
);
|
||||
const token = (createCaptured.body as { token: string }).token;
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await verifyHandler(makeReq({ body: { token } }), res);
|
||||
|
||||
expect(captured.statusCode).toBe(200);
|
||||
expect((captured.body as { allow: boolean }).allow).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a token once the account is suspended', async () => {
|
||||
const { row, sessionActor } = await freshUser();
|
||||
const token = await relayTokenFor(sessionActor);
|
||||
|
||||
await server.stores.user.update(row.id, { suspended: 1 });
|
||||
|
||||
expect(await verifyStatus(token)).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a token once the account is pending verification', async () => {
|
||||
const { row, sessionActor } = await freshUser();
|
||||
const token = await relayTokenFor(sessionActor);
|
||||
|
||||
await server.stores.user.update(row.id, {
|
||||
requires_email_confirmation: 1,
|
||||
email_confirmed: 0,
|
||||
});
|
||||
|
||||
expect(await verifyStatus(token)).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a token whose user no longer exists', async () => {
|
||||
const token = await relayTokenFor({
|
||||
user: { uuid: '00000000-0000-0000-0000-000000000001' },
|
||||
});
|
||||
|
||||
expect(await verifyStatus(token)).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a token once its session is revoked', async () => {
|
||||
const { sessionActor } = await freshUser();
|
||||
const token = await relayTokenFor(sessionActor);
|
||||
expect(await verifyStatus(token)).toBe(200);
|
||||
|
||||
await server.services.auth.revokeSession(sessionActor.session!.uid);
|
||||
|
||||
expect(await verifyStatus(token)).toBe(403);
|
||||
});
|
||||
|
||||
it('binds a token minted by an access token to that token', async () => {
|
||||
const { sessionActor, apiTokenActor, apiToken } = await freshUser();
|
||||
const token = await relayTokenFor(apiTokenActor);
|
||||
expect(await verifyStatus(token)).toBe(200);
|
||||
|
||||
await server.services.auth.revokeAccessToken(
|
||||
sessionActor,
|
||||
apiToken,
|
||||
);
|
||||
|
||||
expect(await verifyStatus(token)).toBe(403);
|
||||
});
|
||||
|
||||
it('applies the account checks to a token with no session claim', async () => {
|
||||
const { row } = await freshUser();
|
||||
const token = server.services.token.sign(
|
||||
'wisp',
|
||||
{ $: 'token:wisp', $v: '0.0.0', user_uid: row.uuid },
|
||||
{ expiresIn: '1d' },
|
||||
);
|
||||
expect(await verifyStatus(token)).toBe(200);
|
||||
|
||||
await server.stores.user.update(row.id, { suspended: 1 });
|
||||
|
||||
expect(await verifyStatus(token)).toBe(403);
|
||||
const body = captured.body as { allow: boolean };
|
||||
expect(body.allow).toBe(true);
|
||||
});
|
||||
|
||||
it('verifies a valid guest token', async () => {
|
||||
|
||||
@@ -18,12 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
assertNotSuspended,
|
||||
assertVerifiedAccount,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import {
|
||||
@@ -76,14 +71,12 @@ export class WispController extends PuterController {
|
||||
const wispCfg = this.#wispConfig();
|
||||
|
||||
if (actor?.user?.uuid) {
|
||||
const sessionUid = await this.#sessionUidFor(actor);
|
||||
const token = this.services.token.sign(
|
||||
'wisp',
|
||||
{
|
||||
$: 'token:wisp',
|
||||
$v: '0.0.0',
|
||||
user_uid: actor.user.uuid,
|
||||
...(sessionUid ? { session_uid: sessionUid } : {}),
|
||||
},
|
||||
{ expiresIn: '1d' },
|
||||
);
|
||||
@@ -127,10 +120,13 @@ export class WispController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const isGuest = Boolean(decoded.guest);
|
||||
const user = isGuest ? null : await this.#liveUserFor(decoded);
|
||||
|
||||
// Build policy event — extensions can deny via extension.on('wisp.get-policy')
|
||||
const isGuest = Boolean(decoded.guest);
|
||||
let user: Record<string, unknown> | null = null;
|
||||
if (!isGuest && decoded.user_uid) {
|
||||
user = await this.stores.user.getByUuid(String(decoded.user_uid));
|
||||
}
|
||||
|
||||
const event: Record<string, unknown> = {
|
||||
allow: true,
|
||||
policy: { allow: true },
|
||||
@@ -149,43 +145,6 @@ export class WispController extends PuterController {
|
||||
res.json(event.policy);
|
||||
};
|
||||
|
||||
/**
|
||||
* The session a relay token lives no longer than. An access token's is its
|
||||
* own session row; the actor doesn't carry it.
|
||||
*/
|
||||
async #sessionUidFor(actor: Actor): Promise<string | undefined> {
|
||||
if (actor.session?.uid) return actor.session.uid;
|
||||
if (!actor.accessToken) return undefined;
|
||||
const row = await this.stores.session.findActiveByAccessTokenUid(
|
||||
actor.accessToken.uid,
|
||||
);
|
||||
return row?.uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* The token's user, held to the same account gates as minting. A token
|
||||
* without `session_uid` (minted before the claim existed, or by a legacy
|
||||
* access token with no session row) gets the account checks only.
|
||||
*/
|
||||
async #liveUserFor(
|
||||
decoded: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const forbidden = () =>
|
||||
new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' });
|
||||
if (!decoded.user_uid) throw forbidden();
|
||||
const user = await this.stores.user.getByUuid(String(decoded.user_uid));
|
||||
if (!user) throw forbidden();
|
||||
assertNotSuspended(user);
|
||||
assertVerifiedAccount(user);
|
||||
if (
|
||||
decoded.session_uid &&
|
||||
!(await this.stores.session.getByUuid(String(decoded.session_uid)))
|
||||
) {
|
||||
throw forbidden();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
#wispConfig(): NonNullable<typeof this.config.wisp> {
|
||||
return this.config.wisp ?? {};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -81,8 +81,6 @@ export type LegacyErrorCodes =
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { PagerSeverity } from '../../types';
|
||||
|
||||
/** Options accepted by `HttpError`. All optional. */
|
||||
export interface HttpErrorOptions {
|
||||
/** Underlying error. Set as the standard `Error.cause`. */
|
||||
@@ -113,11 +111,6 @@ export interface HttpErrorOptions {
|
||||
* serialized to the client.
|
||||
*/
|
||||
noAlarm?: boolean;
|
||||
/**
|
||||
* Alarm at this severity even where the 4xx / `upstream_` rules would skip
|
||||
* it. `noAlarm` still wins. Never serialized to the client.
|
||||
*/
|
||||
alarmSeverity?: PagerSeverity;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,7 +140,6 @@ export class HttpError extends Error {
|
||||
readonly code?: string;
|
||||
readonly fields?: Record<string, unknown>;
|
||||
readonly noAlarm?: boolean;
|
||||
readonly alarmSeverity?: PagerSeverity;
|
||||
|
||||
constructor(
|
||||
statusCode: number,
|
||||
@@ -164,7 +156,6 @@ export class HttpError extends Error {
|
||||
this.code = options.code;
|
||||
this.fields = options.fields;
|
||||
this.noAlarm = options.noAlarm;
|
||||
this.alarmSeverity = options.alarmSeverity;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ declare global {
|
||||
|
||||
rawBody?: Buffer;
|
||||
|
||||
/** User-agent, parsed from the header on first read. */
|
||||
/** Parsed user-agent, populated by the global UA-parsing middleware. */
|
||||
ua?: {
|
||||
browser: { name?: string; version?: string; major?: string };
|
||||
os: { name?: string; version?: string };
|
||||
|
||||
@@ -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),
|
||||
@@ -188,7 +175,7 @@ describe('createUserSubdomainNotFound', () => {
|
||||
|
||||
it('passes through reserved subdomains (api, js, native apps, etc.)', () => {
|
||||
// `api`, `js`, `dav`, `docs`, `developer`, `editor`, `pdf`,
|
||||
// `puter-app-icons`, etc. all pass through.
|
||||
// `puter-app-icons`, `onlyoffice`, etc. all bypass.
|
||||
for (const sub of ['api', 'js', 'docs', 'editor', 'puter-app-icons']) {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
@@ -202,17 +189,6 @@ describe('createUserSubdomainNotFound', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('404s an extension-owned subdomain nobody has claimed', () => {
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
makeReq({
|
||||
subdomains: ['com', 'puter', 'onlyoffice'],
|
||||
host: 'onlyoffice.puter.com',
|
||||
}),
|
||||
);
|
||||
expectNotFound(next);
|
||||
});
|
||||
|
||||
it('passes through when no subdomain is present (root)', () => {
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
@@ -664,21 +640,6 @@ describe('createUserSubdomainNotFound with claimed subdomains', () => {
|
||||
expect(args).toEqual([]);
|
||||
});
|
||||
|
||||
it('hands a main-pipeline claim to the rest of the stack, like a reserved name', async () => {
|
||||
const claims = lookup({ office: { mainPipeline: true } });
|
||||
for (const [host, subdomains] of [
|
||||
['office.puter.com', ['office']],
|
||||
['office.puter.com:4100', ['office']],
|
||||
['office.x.puter.com', ['x', 'office']],
|
||||
] as const) {
|
||||
const args = await runClaimed(
|
||||
createUserSubdomainNotFound(config, claims),
|
||||
hostReq(host, [...subdomains]),
|
||||
);
|
||||
expect(args, host).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('looks the claim up on every request', async () => {
|
||||
const claims: Record<string, ResolvedClaim> = {};
|
||||
const middleware = createUserSubdomainNotFound(config, lookup(claims));
|
||||
@@ -689,32 +650,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 };
|
||||
|
||||
@@ -742,7 +677,7 @@ describe('addClaimedSubdomain', () => {
|
||||
});
|
||||
|
||||
it('rejects subdomains core already handles', () => {
|
||||
for (const name of ['www', 'api', 'js', 'dav', 'docs']) {
|
||||
for (const name of ['www', 'api', 'js', 'dav', 'docs', 'onlyoffice']) {
|
||||
expect(() => addClaimedSubdomain(new Map(), name, claim)).toThrow(
|
||||
/reserved/,
|
||||
);
|
||||
@@ -771,26 +706,6 @@ describe('addClaimedSubdomain', () => {
|
||||
).toThrow(/invalid checkRoute/);
|
||||
});
|
||||
|
||||
it('records a main-pipeline claim, and refuses one that also carries a gate', () => {
|
||||
const claims = new Map<string, SubdomainClaim>();
|
||||
addClaimedSubdomain(claims, 'onlyoffice', { mainPipeline: true });
|
||||
expect(claims.get('onlyoffice')).toEqual({ mainPipeline: true });
|
||||
expect(() =>
|
||||
addClaimedSubdomain(claims, 'onlyoffice', { mainPipeline: true }),
|
||||
).toThrow(/already claimed/);
|
||||
for (const gate of [
|
||||
{ authorize: () => true },
|
||||
{ checkRoute: () => undefined },
|
||||
]) {
|
||||
expect(() =>
|
||||
addClaimedSubdomain(new Map(), 'portal', {
|
||||
mainPipeline: true,
|
||||
...gate,
|
||||
} as unknown as SubdomainClaim),
|
||||
).toThrow(/main pipeline with a gate/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a claim without authorize', () => {
|
||||
for (const bad of [undefined, {}, { authorize: true }]) {
|
||||
expect(() =>
|
||||
@@ -891,18 +806,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),
|
||||
|
||||
@@ -41,9 +41,8 @@ const NATIVE_APP_SUBDOMAINS = [
|
||||
const NATIVE_APPS_WITH_DIST = new Set(['docs', 'developer']);
|
||||
|
||||
/**
|
||||
* Subdomains that v2 serves itself. A root-domain subdomain neither in this set
|
||||
* nor claimed by an extension is treated as a user-defined site and rejected
|
||||
* with a 404.
|
||||
* Subdomains that v2 serves itself. Anything NOT in this set that lives on the
|
||||
* root domain is treated as a user-defined site and rejected with a 404.
|
||||
*
|
||||
* Kept as a plain Set so `has()` is O(1); order doesn't matter.
|
||||
*/
|
||||
@@ -56,6 +55,8 @@ const RESERVED_SUBDOMAINS = new Set<string>([
|
||||
...NATIVE_APP_SUBDOMAINS,
|
||||
// App-icon serving subdomain.
|
||||
'puter-app-icons',
|
||||
// Extension-owned subdomains.
|
||||
'onlyoffice',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -68,8 +69,7 @@ export type ClaimAuthorize = (
|
||||
res: Response,
|
||||
) => boolean | Promise<boolean>;
|
||||
|
||||
/** A claim answered on its own router, only past `authorize`. */
|
||||
export interface GatedSubdomainClaim {
|
||||
export interface SubdomainClaim {
|
||||
authorize: ClaimAuthorize;
|
||||
/**
|
||||
* Vets each route declared for the claim as the server builds it; a throw
|
||||
@@ -78,39 +78,16 @@ export interface GatedSubdomainClaim {
|
||||
checkRoute?: (options: RouteOptions, label: string) => void;
|
||||
}
|
||||
|
||||
/** Served like core's own subdomains: CORS, auth and every route. */
|
||||
export interface MainPipelineSubdomainClaim {
|
||||
mainPipeline: true;
|
||||
/** A claim as dispatch sees it: its gate, plus the routes served behind it. */
|
||||
export interface ResolvedClaim {
|
||||
authorize: ClaimAuthorize;
|
||||
handler?: RequestHandler;
|
||||
}
|
||||
|
||||
export type SubdomainClaim = GatedSubdomainClaim | MainPipelineSubdomainClaim;
|
||||
|
||||
/** A claim as dispatch sees it: a gate and the routes behind it, or neither. */
|
||||
export type ResolvedClaim =
|
||||
| { authorize: ClaimAuthorize; handler?: RequestHandler }
|
||||
| MainPipelineSubdomainClaim;
|
||||
|
||||
export const isMainPipelineClaim = (
|
||||
claim: SubdomainClaim | ResolvedClaim | undefined,
|
||||
): claim is MainPipelineSubdomainClaim =>
|
||||
(claim as { mainPipeline?: unknown } | undefined)?.mainPipeline === true;
|
||||
|
||||
export type ClaimLookup = (name: string) => ResolvedClaim | undefined;
|
||||
|
||||
const SUBDOMAIN_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
/** The label `x` when `host` is exactly `x.<domain>`, ignoring case and port. */
|
||||
export const subdomainLabelOf = (
|
||||
host: string,
|
||||
domain: string,
|
||||
): string | undefined => {
|
||||
const hostName = host.toLowerCase().split(':')[0];
|
||||
const base = domain.toLowerCase().split(':')[0];
|
||||
if (!base || !hostName.endsWith(`.${base}`)) return undefined;
|
||||
const label = hostName.slice(0, -(base.length + 1));
|
||||
return label && !label.includes('.') ? label : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Record a claim on `<name>.<domain>`. Throws on a malformed or uppercase
|
||||
* label, a subdomain core already handles, or a name that is already claimed.
|
||||
@@ -129,15 +106,6 @@ export const addClaimedSubdomain = (
|
||||
if (claims.has(name)) {
|
||||
throw new Error(`Subdomain '${name}' is already claimed`);
|
||||
}
|
||||
if (isMainPipelineClaim(claim)) {
|
||||
if ('authorize' in claim || 'checkRoute' in claim) {
|
||||
throw new Error(
|
||||
`Subdomain '${name}' claimed for the main pipeline with a gate`,
|
||||
);
|
||||
}
|
||||
claims.set(name, { mainPipeline: true });
|
||||
return;
|
||||
}
|
||||
if (typeof claim?.authorize !== 'function') {
|
||||
throw new Error(`Subdomain '${name}' claimed without authorize`);
|
||||
}
|
||||
@@ -163,7 +131,7 @@ const subdomainNotFound = () =>
|
||||
* unclaimed-subdomain 404, and errors reach the error handler.
|
||||
*/
|
||||
const dispatchClaimed = async (
|
||||
claim: Exclude<ResolvedClaim, MainPipelineSubdomainClaim>,
|
||||
claim: ResolvedClaim,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
@@ -207,8 +175,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();
|
||||
@@ -224,8 +190,7 @@ export const createWwwRedirect = (config: IConfig): RequestHandler => {
|
||||
* Passes through when:
|
||||
*
|
||||
* - No active subdomain (root)
|
||||
* - Active subdomain is reserved (api, js, native apps, …) or claimed for the
|
||||
* main pipeline
|
||||
* - Active subdomain is reserved (api, js, native apps, …)
|
||||
* - Host is on one of the hosting domains (they may nest under `config.domain`)
|
||||
* - Host is a local worker host and the local worker server is on
|
||||
* - Host isn't under `config.domain` (custom domains, other hosts)
|
||||
@@ -258,8 +223,6 @@ export const createUserSubdomainNotFound = (
|
||||
req.subdomains?.[req.subdomains.length - 1] ?? ''
|
||||
).toLowerCase();
|
||||
if (active === '' || RESERVED_SUBDOMAINS.has(active)) return next();
|
||||
const claim = lookupClaim(active);
|
||||
if (isMainPipelineClaim(claim)) return next();
|
||||
|
||||
const hostName = (req.headers.host ?? '').toLowerCase().split(':')[0];
|
||||
if (
|
||||
@@ -276,9 +239,8 @@ export const createUserSubdomainNotFound = (
|
||||
// responder, body parsing, auth and every route, `subdomain: '*'` ones
|
||||
// included. One label deep only: `<name>.x.<domain>` is not the claim.
|
||||
const claimed =
|
||||
req.subdomains?.length === 1 &&
|
||||
subdomainLabelOf(hostName, domain) === active
|
||||
? claim
|
||||
req.subdomains?.length === 1 && hostName === `${active}.${domain}`
|
||||
? lookupClaim(active)
|
||||
: undefined;
|
||||
if (claimed) {
|
||||
void dispatchClaimed(claimed, req, res, next);
|
||||
@@ -307,7 +269,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();
|
||||
|
||||
@@ -669,7 +669,7 @@ describe('resolveOwnedAppForHostedSite', () => {
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
site: { user_id: owner.id },
|
||||
appStore: server.stores.app,
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
});
|
||||
expect(out?.uid).toBe(app.uid);
|
||||
@@ -684,7 +684,7 @@ describe('resolveOwnedAppForHostedSite', () => {
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.app.puter.localhost'),
|
||||
site: { user_id: owner.id },
|
||||
appStore: server.stores.app,
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
});
|
||||
expect(out?.uid).toBe(app.uid);
|
||||
@@ -700,7 +700,7 @@ describe('resolveOwnedAppForHostedSite', () => {
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
site: { user_id: a.id },
|
||||
appStore: server.stores.app,
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
@@ -716,7 +716,7 @@ describe('resolveOwnedAppForHostedSite', () => {
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
site: { user_id: owner.id },
|
||||
appStore: server.stores.app,
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
});
|
||||
expect(out?.uid).toBe(app.uid);
|
||||
@@ -732,7 +732,7 @@ describe('resolveOwnedAppForHostedSite', () => {
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
site: { user_id: owner.id },
|
||||
appStore: server.stores.app,
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
requirePrivate: true,
|
||||
});
|
||||
@@ -753,54 +753,18 @@ describe('resolveOwnedAppForHostedSite', () => {
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.app.puter.localhost'),
|
||||
site: { user_id: owner.id },
|
||||
appStore: server.stores.app,
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
});
|
||||
expect(out?.uid).toBe(app.uid);
|
||||
expect(Boolean(out?.is_private)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves columns the gate never reads, such as an inline icon, out of the match', async () => {
|
||||
const owner = await makeUser();
|
||||
const app = await createPrivateApp(
|
||||
owner.id,
|
||||
'http://beans.site.puter.localhost/',
|
||||
);
|
||||
await server.clients.db.write(
|
||||
'UPDATE `apps` SET `icon` = ?, `description` = ? WHERE `uid` = ?',
|
||||
[`data:image/png;base64,${'A'.repeat(512)}`, 'about', app.uid],
|
||||
);
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
site: { user_id: owner.id },
|
||||
appStore: server.stores.app,
|
||||
config: baseConfig(),
|
||||
});
|
||||
expect(Object.keys(out!).sort()).toEqual([
|
||||
'id',
|
||||
'index_url',
|
||||
'is_private',
|
||||
'name',
|
||||
'owner_user_id',
|
||||
'title',
|
||||
'uid',
|
||||
]);
|
||||
expect(out).toMatchObject({
|
||||
id: app.id,
|
||||
uid: app.uid,
|
||||
name: `private-${app.uid}`,
|
||||
title: `private-${app.uid}`,
|
||||
owner_user_id: owner.id,
|
||||
index_url: 'http://beans.site.puter.localhost/',
|
||||
});
|
||||
expect(Boolean(out!.is_private)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null when the site has no owner', async () => {
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
site: { user_id: null },
|
||||
appStore: server.stores.app,
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
@@ -810,7 +774,7 @@ describe('resolveOwnedAppForHostedSite', () => {
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf(''),
|
||||
site: { user_id: 1 },
|
||||
appStore: server.stores.app,
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
@@ -950,8 +914,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 +934,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 +947,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,
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { AbstractDatabaseClient } from '@heyputer/backend/src/clients/database/DatabaseClient';
|
||||
import type { Request } from 'express';
|
||||
import {
|
||||
HOSTED_ASSET_COOKIES,
|
||||
type AuthService,
|
||||
} 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
|
||||
@@ -193,15 +193,13 @@ export function subdomainFromHost(
|
||||
export async function resolveOwnedAppForHostedSite(opts: {
|
||||
req: Request;
|
||||
site: SubdomainLike;
|
||||
appStore: Pick<AppStore, 'listOwnedByIndexUrlCandidates'>;
|
||||
db: AbstractDatabaseClient;
|
||||
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);
|
||||
@@ -249,11 +247,15 @@ export async function resolveOwnedAppForHostedSite(opts: {
|
||||
const uniqueCandidates = [...new Set(urlCandidates)];
|
||||
if (uniqueCandidates.length === 0) return null;
|
||||
|
||||
const privateFilter = opts.requirePrivate
|
||||
? `AND \`is_private\` = ${opts.db.booleanLiteral(true)} `
|
||||
: '';
|
||||
const placeholders = uniqueCandidates.map(() => '?').join(', ');
|
||||
// Ambiguity fails closed: a private row wins; `id` keeps it deterministic.
|
||||
const rows = await opts.appStore.listOwnedByIndexUrlCandidates(
|
||||
opts.site.user_id,
|
||||
uniqueCandidates,
|
||||
{ requirePrivate: opts.requirePrivate },
|
||||
const orderClause = `ORDER BY CASE WHEN \`is_private\` = ${opts.db.booleanLiteral(true)} THEN 0 ELSE 1 END, \`id\``;
|
||||
const rows = await opts.db.read(
|
||||
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) ${orderClause} LIMIT 2`,
|
||||
[opts.site.user_id, ...uniqueCandidates],
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
if (rows.length > 1) {
|
||||
@@ -460,15 +462,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;
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { Writable } from 'node:stream';
|
||||
import type { Request, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -655,66 +654,6 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
expect(out.headers['Accept-Ranges']).toBe('bytes');
|
||||
});
|
||||
|
||||
it('cuts the response off when storage fails mid-download', async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
|
||||
const sub = `cut-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
rootDirId: homeEntry!.id,
|
||||
});
|
||||
const entry = await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/movie.bin`,
|
||||
Buffer.alloc(64, 0x42),
|
||||
);
|
||||
|
||||
// One chunk, then the storage connection drops.
|
||||
let reads = 0;
|
||||
const body = new Readable({
|
||||
read() {
|
||||
reads += 1;
|
||||
if (reads === 1) this.push('partial');
|
||||
else this.destroy(new Error('aborted'));
|
||||
},
|
||||
});
|
||||
const client = server.clients.s3.get(
|
||||
server.stores.s3Object.resolveRegion(entry!.bucketRegion),
|
||||
);
|
||||
const send = client.send.bind(client);
|
||||
const spy = vi
|
||||
.spyOn(client, 'send')
|
||||
.mockImplementation(((command: unknown) =>
|
||||
command instanceof GetObjectCommand
|
||||
? Promise.resolve({ Body: body, ContentLength: 64 })
|
||||
: send(command as never)) as never);
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res } = makeRes();
|
||||
// A real response is torn down through its socket and never emits
|
||||
// 'error' itself; the stand-in Writable does, so absorb it.
|
||||
res.on('error', () => undefined);
|
||||
try {
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/movie.bin',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(res.destroyed).toBe(true);
|
||||
expect(res.writableFinished).toBe(false);
|
||||
expect(body.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("rewrites a trailing slash request to /index.html under the site's root", async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
@@ -1030,133 +969,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 +1193,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();
|
||||
@@ -1400,50 +1217,6 @@ describe('createPuterSiteMiddleware — .puter_site_config', () => {
|
||||
expect(piped!.equals(body)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the config only for a path that 404s', async () => {
|
||||
const { owner, homePath, sub } = await setupSiteWithConfig({
|
||||
errors: { '404': { file: '/index.html', status: 200 } },
|
||||
});
|
||||
const body = Buffer.from('<html>spa-shell</html>');
|
||||
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
|
||||
const root = await server.stores.fsEntry.getEntryByPath(homePath);
|
||||
// The loader caches whatever it reads, so an empty slot means it
|
||||
// never ran.
|
||||
const cached = () =>
|
||||
server.clients.redis.get(`puter-site-config:${root!.id}`);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const served = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/index.html',
|
||||
}),
|
||||
served.res,
|
||||
vi.fn(),
|
||||
);
|
||||
await settle();
|
||||
expect(served.out.statusCode).toBe(200);
|
||||
expect(await cached()).toBeNull();
|
||||
|
||||
const missing = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/client-route',
|
||||
}),
|
||||
missing.res,
|
||||
vi.fn(),
|
||||
);
|
||||
await settle();
|
||||
expect(missing.out.statusCode).toBe(200);
|
||||
expect((missing.out.body as Buffer | undefined)?.equals(body)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await cached()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('serves the cached config on a second request, even when the on-disk file is removed (Redis cache holds within TTL)', async () => {
|
||||
// First request seeds the cache with the parsed config. We
|
||||
// then delete the underlying file and fire a second request:
|
||||
@@ -1495,9 +1268,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 () => {
|
||||
@@ -1689,86 +1462,6 @@ describe('createPuterSiteMiddleware — __workers/ is never served', () => {
|
||||
expect(Buffer.isBuffer(piped)).toBe(true);
|
||||
expect(piped!.equals(shell)).toBe(true);
|
||||
});
|
||||
|
||||
// SQLite compares paths bytewise; MySQL's utf8mb4_unicode_ci folds
|
||||
// accents, fullwidth forms and zero-width chars. Emulate that fold.
|
||||
const withCollationAlias = async (
|
||||
alias: string,
|
||||
real: string,
|
||||
fn: () => Promise<void>,
|
||||
) => {
|
||||
const store = server.stores.fsEntry;
|
||||
const original = store.getEntryByPath.bind(store);
|
||||
const spy = vi
|
||||
.spyOn(store, 'getEntryByPath')
|
||||
.mockImplementation((path, options) =>
|
||||
original(
|
||||
path.startsWith(alias)
|
||||
? real + path.slice(alias.length)
|
||||
: path,
|
||||
options,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
};
|
||||
|
||||
it('404s a collation alias of __workers/ that the database resolves to the real file', async () => {
|
||||
const { homePath, sub } = await setupSiteWithWorker();
|
||||
await withCollationAlias(
|
||||
`${homePath}/__wörkers/matchmaking.worker.js`,
|
||||
`${homePath}/__workers/matchmaking.worker.js`,
|
||||
async () => {
|
||||
const out = await request(
|
||||
sub,
|
||||
'/__w%C3%B6rkers/matchmaking.worker.js',
|
||||
);
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(String(out.body)).not.toContain('secret');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('404s a collation alias of a folder whose index.html sits in __workers/', async () => {
|
||||
const { owner, homePath, sub } = await setupSiteWithWorker();
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/__workers/index.html`,
|
||||
Buffer.from('<html>listing</html>'),
|
||||
'text/html',
|
||||
);
|
||||
await withCollationAlias(
|
||||
`${homePath}/__wörkers`,
|
||||
`${homePath}/__workers`,
|
||||
async () => {
|
||||
const out = await request(sub, '/__w%C3%B6rkers');
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(String(out.body)).not.toContain('listing');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('404s a collation alias of .puter_site_config', async () => {
|
||||
const { owner, homePath, sub } = await setupSiteWithWorker();
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/.puter_site_config`,
|
||||
Buffer.from(JSON.stringify({ errors: {}, marker: 'cfg-body' })),
|
||||
'application/json',
|
||||
);
|
||||
await withCollationAlias(
|
||||
`${homePath}/.püter_site_config`,
|
||||
`${homePath}/.puter_site_config`,
|
||||
async () => {
|
||||
const out = await request(sub, '/.p%C3%BCter_site_config');
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(String(out.body)).not.toContain('cfg-body');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPuterSiteMiddleware — hosting CSP', () => {
|
||||
@@ -2131,463 +1824,3 @@ describe('createPuterSiteMiddleware — private app sign-in token', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Owner, app and root lookups ─────────────────────────────────────
|
||||
|
||||
describe('createPuterSiteMiddleware — site lookups', () => {
|
||||
const failure = (what: string) => new Error(`${what} lookup failed`);
|
||||
const isAppQuery = (sql: unknown) => /FROM `?apps`?\b/.test(String(sql));
|
||||
|
||||
/** Unhandled rejections raised while `fn` runs, and shortly after. */
|
||||
const unhandledDuring = async (fn: () => Promise<void>) => {
|
||||
const seen: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown) => seen.push(reason);
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
try {
|
||||
await fn();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
}
|
||||
return seen;
|
||||
};
|
||||
|
||||
const makeSite = async (opts: { suspended?: boolean } = {}) => {
|
||||
const owner = await makeUserWithHome();
|
||||
if (opts.suspended) {
|
||||
await server.stores.user.update(owner.id, { suspended: 1 });
|
||||
}
|
||||
const homePath = `/${owner.username}`;
|
||||
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
|
||||
const sub = `look-${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>hi</html>'),
|
||||
'text/html',
|
||||
);
|
||||
return { owner, sub, rootDirId: homeEntry!.id };
|
||||
};
|
||||
|
||||
const failRootLookup = () =>
|
||||
vi
|
||||
.spyOn(server.stores.fsEntry, 'getEntryById')
|
||||
.mockRejectedValueOnce(failure('root'));
|
||||
|
||||
const failAppLookup = () => {
|
||||
const db = server.clients.db;
|
||||
const realRead = db.read.bind(db);
|
||||
return vi
|
||||
.spyOn(db, 'read')
|
||||
.mockImplementation((sql, params) =>
|
||||
isAppQuery(sql)
|
||||
? Promise.reject(failure('app'))
|
||||
: realRead(sql, params),
|
||||
);
|
||||
};
|
||||
|
||||
/** Spies on app queries and root lookups, failing both. */
|
||||
const failAndCountLookups = () => {
|
||||
const readSpy = failAppLookup();
|
||||
const rootSpy = vi
|
||||
.spyOn(server.stores.fsEntry, 'getEntryById')
|
||||
.mockRejectedValue(failure('root'));
|
||||
return {
|
||||
appQueries: () =>
|
||||
readSpy.mock.calls.filter(([sql]) => isAppQuery(sql)).length,
|
||||
rootLookups: () => rootSpy.mock.calls.length,
|
||||
restore: () => {
|
||||
readSpy.mockRestore();
|
||||
rootSpy.mockRestore();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it('starts the app and root lookups together once the owner is known', async () => {
|
||||
const { owner, sub, rootDirId } = await makeSite();
|
||||
const ownerRow = await server.stores.user.getById(owner.id);
|
||||
let releaseOwner!: () => void;
|
||||
const userSpy = vi
|
||||
.spyOn(server.stores.user, 'getById')
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseOwner = () => resolve(ownerRow);
|
||||
}),
|
||||
);
|
||||
const rootSpy = vi.spyOn(server.stores.fsEntry, 'getEntryById');
|
||||
const db = server.clients.db;
|
||||
const realRead = db.read.bind(db);
|
||||
const heldAppQueries: Array<() => void> = [];
|
||||
const readSpy = vi
|
||||
.spyOn(db, 'read')
|
||||
.mockImplementation(async (sql, params) => {
|
||||
if (isAppQuery(sql)) {
|
||||
await new Promise<void>((resolve) => {
|
||||
heldAppQueries.push(resolve);
|
||||
});
|
||||
}
|
||||
return realRead(sql, params);
|
||||
});
|
||||
const appQueried = () =>
|
||||
readSpy.mock.calls.some(([sql]) => isAppQuery(sql));
|
||||
try {
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
const done = mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/index.html',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(appQueried()).toBe(false);
|
||||
expect(rootSpy).not.toHaveBeenCalled();
|
||||
|
||||
releaseOwner();
|
||||
await vi.waitFor(() => expect(appQueried()).toBe(true));
|
||||
expect(rootSpy).toHaveBeenCalledWith(rootDirId);
|
||||
|
||||
for (const release of heldAppQueries.splice(0)) release();
|
||||
await done;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(out.statusCode).toBe(200);
|
||||
} finally {
|
||||
for (const release of heldAppQueries.splice(0)) release();
|
||||
userSpy.mockRestore();
|
||||
rootSpy.mockRestore();
|
||||
readSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['suspended', { suspended: true }],
|
||||
['missing', {}],
|
||||
] as const)(
|
||||
'404s a %s owner without querying the app or the root',
|
||||
async (kind, opts) => {
|
||||
const { sub } = await makeSite(opts);
|
||||
const userSpy =
|
||||
kind === 'missing'
|
||||
? vi
|
||||
.spyOn(server.stores.user, 'getById')
|
||||
.mockResolvedValueOnce(null)
|
||||
: null;
|
||||
const lookups = failAndCountLookups();
|
||||
try {
|
||||
let out: CapturedRes | undefined;
|
||||
const unhandled = await unhandledDuring(async () => {
|
||||
({ out } = await runMiddleware(
|
||||
buildMiddleware(),
|
||||
makeReq({ hostname: `${sub}.site.puter.localhost` }),
|
||||
));
|
||||
});
|
||||
expect(out?.statusCode).toBe(404);
|
||||
expect(out?.body).toBe('Subdomain not found');
|
||||
expect(lookups.appQueries()).toBe(0);
|
||||
expect(lookups.rootLookups()).toBe(0);
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
userSpy?.mockRestore();
|
||||
lookups.restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("surfaces the owner lookup's failure before the others start", async () => {
|
||||
const { sub } = await makeSite();
|
||||
const userSpy = vi
|
||||
.spyOn(server.stores.user, 'getById')
|
||||
.mockRejectedValueOnce(failure('owner'));
|
||||
const lookups = failAndCountLookups();
|
||||
try {
|
||||
const unhandled = await unhandledDuring(async () => {
|
||||
await expect(
|
||||
runMiddleware(
|
||||
buildMiddleware(),
|
||||
makeReq({ hostname: `${sub}.site.puter.localhost` }),
|
||||
),
|
||||
).rejects.toThrow('owner lookup failed');
|
||||
});
|
||||
expect(lookups.appQueries()).toBe(0);
|
||||
expect(lookups.rootLookups()).toBe(0);
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
userSpy.mockRestore();
|
||||
lookups.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces the app lookup's failure ahead of the root's", async () => {
|
||||
const { sub } = await makeSite();
|
||||
const readSpy = failAppLookup();
|
||||
const rootSpy = failRootLookup();
|
||||
try {
|
||||
const unhandled = await unhandledDuring(async () => {
|
||||
await expect(
|
||||
runMiddleware(
|
||||
buildMiddleware(),
|
||||
makeReq({ hostname: `${sub}.site.puter.localhost` }),
|
||||
),
|
||||
).rejects.toThrow('app lookup failed');
|
||||
});
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
readSpy.mockRestore();
|
||||
rootSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces a failed root lookup once the gate lets the request through', async () => {
|
||||
const { sub } = await makeSite();
|
||||
const rootSpy = failRootLookup();
|
||||
try {
|
||||
const unhandled = await unhandledDuring(async () => {
|
||||
await expect(
|
||||
runMiddleware(
|
||||
buildMiddleware(),
|
||||
makeReq({ hostname: `${sub}.site.puter.localhost` }),
|
||||
),
|
||||
).rejects.toThrow('root lookup failed');
|
||||
});
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
rootSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('redirects off the private host and drops the failed root lookup', async () => {
|
||||
const { sub } = await makeSite();
|
||||
const rootSpy = failRootLookup();
|
||||
try {
|
||||
let out: CapturedRes | undefined;
|
||||
const unhandled = await unhandledDuring(async () => {
|
||||
({ out } = await runMiddleware(
|
||||
buildMiddleware(),
|
||||
makeReq({ hostname: `${sub}.app.puter.localhost` }),
|
||||
));
|
||||
});
|
||||
expect(out?.redirected).toEqual({
|
||||
status: 302,
|
||||
url: `http://${sub}.site.puter.localhost/`,
|
||||
});
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
rootSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('asks a private-app visitor to sign in and drops the failed root lookup', async () => {
|
||||
const { owner, sub } = await makeSite();
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`, `is_private`) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
`app-${uuidv4()}`,
|
||||
sub,
|
||||
`Title ${sub}`,
|
||||
`http://${sub}.app.puter.localhost/`,
|
||||
owner.id,
|
||||
1,
|
||||
],
|
||||
);
|
||||
const rootSpy = failRootLookup();
|
||||
try {
|
||||
let out: CapturedRes | undefined;
|
||||
const unhandled = await unhandledDuring(async () => {
|
||||
({ out } = await runMiddleware(
|
||||
buildMiddleware(),
|
||||
makeReq({ hostname: `${sub}.app.puter.localhost` }),
|
||||
));
|
||||
});
|
||||
expect(out?.statusCode).toBe(200);
|
||||
expect(String(out?.body)).toContain(`Title ${sub}`);
|
||||
expect(String(out?.body)).toContain('Sign in required');
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
rootSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Private-app gate decisions ──────────────────────────────────────
|
||||
|
||||
describe('createPuterSiteMiddleware — private-app gate decisions', () => {
|
||||
let entitled = false;
|
||||
const listener = (_key: string, data: { result: { allowed: boolean } }) => {
|
||||
if (entitled) data.result.allowed = true;
|
||||
};
|
||||
let visitor: Awaited<ReturnType<typeof makeUser>>;
|
||||
let token: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
visitor = await makeUser();
|
||||
({ token } = await server.services.auth.createSessionToken(
|
||||
visitor as never,
|
||||
));
|
||||
server.clients.event.on('app.privateAccess.check', listener as never);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.clients.event.off('app.privateAccess.check', listener as never);
|
||||
});
|
||||
|
||||
const makePrivateSite = async (opts: {
|
||||
suspended?: boolean;
|
||||
root?: 'home' | 'none' | 'foreign';
|
||||
}) => {
|
||||
const owner = await makeUserWithHome();
|
||||
const sub = `gate-${Math.random().toString(36).slice(2, 8)}`;
|
||||
let rootDirId: number | undefined;
|
||||
if (opts.root === 'home') {
|
||||
rootDirId = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}`,
|
||||
))!.id;
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`/${owner.username}/index.html`,
|
||||
Buffer.from('<html>gated</html>'),
|
||||
'text/html',
|
||||
);
|
||||
} else if (opts.root === 'foreign') {
|
||||
const other = await makeUserWithHome();
|
||||
rootDirId = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${other.username}`,
|
||||
))!.id;
|
||||
}
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
...(rootDirId !== undefined ? { rootDirId } : {}),
|
||||
});
|
||||
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}.app.puter.localhost/`,
|
||||
owner.id,
|
||||
1,
|
||||
],
|
||||
);
|
||||
if (opts.suspended) {
|
||||
await server.stores.user.update(owner.id, { suspended: 1 });
|
||||
}
|
||||
return sub;
|
||||
};
|
||||
|
||||
const visit = async (
|
||||
sub: string,
|
||||
host: 'site' | 'app',
|
||||
identity: 'none' | 'cookie',
|
||||
) => {
|
||||
const mw = buildMiddleware({ cookie_name: 'puter_session' });
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.${host}.puter.localhost`,
|
||||
path: '/index.html',
|
||||
cookies: identity === 'cookie' ? { puter_session: token } : {},
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return out;
|
||||
};
|
||||
|
||||
const decide = (out: CapturedRes, sub: string) => {
|
||||
if (out.redirected) {
|
||||
return `302 ${out.redirected.url.replaceAll(sub, 'SUB')}`;
|
||||
}
|
||||
const body = Buffer.isBuffer(out.body)
|
||||
? out.body.toString()
|
||||
: String(out.body);
|
||||
if (body.includes('Sign in required'))
|
||||
return `${out.statusCode} sign-in`;
|
||||
if (body.includes('Subdomain or site is not pointing')) {
|
||||
return `${out.statusCode} site-404`;
|
||||
}
|
||||
return `${out.statusCode} ${body}`;
|
||||
};
|
||||
|
||||
it.each([
|
||||
// A suspended owner hides the app entirely, whoever asks.
|
||||
[
|
||||
{ suspended: true, root: 'home' },
|
||||
'app',
|
||||
'cookie',
|
||||
true,
|
||||
'404 Subdomain not found',
|
||||
],
|
||||
[
|
||||
{ suspended: true, root: 'home' },
|
||||
'app',
|
||||
'none',
|
||||
false,
|
||||
'404 Subdomain not found',
|
||||
],
|
||||
[
|
||||
{ suspended: true, root: 'home' },
|
||||
'site',
|
||||
'cookie',
|
||||
true,
|
||||
'404 Subdomain not found',
|
||||
],
|
||||
// The public host always bounces to the private one first.
|
||||
[
|
||||
{ root: 'home' },
|
||||
'site',
|
||||
'cookie',
|
||||
true,
|
||||
'302 http://SUB.app.puter.localhost/index.html',
|
||||
],
|
||||
[
|
||||
{ root: 'home' },
|
||||
'site',
|
||||
'none',
|
||||
false,
|
||||
'302 http://SUB.app.puter.localhost/index.html',
|
||||
],
|
||||
[{ root: 'home' }, 'app', 'none', true, '200 sign-in'],
|
||||
[
|
||||
{ root: 'home' },
|
||||
'app',
|
||||
'cookie',
|
||||
false,
|
||||
'302 https://puter.localhost/app/app-center/?item=SUB',
|
||||
],
|
||||
[{ root: 'home' }, 'app', 'cookie', true, '200 <html>gated</html>'],
|
||||
// Root checks run only after entitlement passes.
|
||||
[
|
||||
{ root: 'none' },
|
||||
'app',
|
||||
'cookie',
|
||||
false,
|
||||
'302 https://puter.localhost/app/app-center/?item=SUB',
|
||||
],
|
||||
[{ root: 'none' }, 'app', 'cookie', true, '404 site-404'],
|
||||
[{ root: 'foreign' }, 'app', 'none', false, '200 sign-in'],
|
||||
[{ root: 'foreign' }, 'app', 'cookie', true, '404 site-404'],
|
||||
] as const)(
|
||||
'%o on %s host, identity %s, entitled %s → %s',
|
||||
async (site, host, identity, allow, expected) => {
|
||||
const sub = await makePrivateSite(site);
|
||||
entitled = allow;
|
||||
try {
|
||||
expect(decide(await visit(sub, host, identity), sub)).toBe(
|
||||
expected,
|
||||
);
|
||||
} finally {
|
||||
entitled = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -26,7 +26,6 @@ import { HOSTED_ASSET_COOKIES } from '../../../services/auth/AuthService';
|
||||
import { MANAGE_PERM_PREFIX } from '../../../services/permission/consts';
|
||||
import type { puterStores } from '../../../stores';
|
||||
import type { IConfig, LayerInstances } from '../../../types';
|
||||
import { awaitLater } from '../../../util/concurrency';
|
||||
import { makeActor } from '../../actor';
|
||||
import {
|
||||
buildAppCenterFallback,
|
||||
@@ -61,12 +60,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:
|
||||
*
|
||||
@@ -95,20 +94,6 @@ const isWorkersSourcePath = (urlPath: string): boolean =>
|
||||
.split('/')
|
||||
.some((segment) => segment.toLowerCase() === WORKERS_FOLDER);
|
||||
|
||||
/**
|
||||
* Checks the stored path of a looked-up entry. The URL-level guards are not
|
||||
* enough on MySQL: `fsentries.path` is utf8mb4_unicode_ci, so `__wörkers`,
|
||||
* fullwidth or zero-width spellings resolve to the real `__workers` row.
|
||||
*/
|
||||
const isServableEntryPath = (
|
||||
entryPath: string | null | undefined,
|
||||
rootPath: string,
|
||||
): boolean => {
|
||||
if (!entryPath?.startsWith(`${rootPath}/`)) return false;
|
||||
const relPath = entryPath.slice(rootPath.length);
|
||||
return !isWorkersSourcePath(relPath) && !isSiteConfigPath(relPath);
|
||||
};
|
||||
|
||||
/** Inside some owner's top-level Trash, which is where Delete puts things. */
|
||||
const isTrashedPath = (path: string): boolean =>
|
||||
/^\/[^/]+\/Trash(\/|$)/u.test(path);
|
||||
@@ -228,60 +213,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(
|
||||
@@ -301,49 +255,23 @@ export const createPuterSiteMiddleware = (
|
||||
return;
|
||||
}
|
||||
|
||||
// The hosted app and the site root depend only on the site row, so
|
||||
// both lookups run together. Each is awaited where it is read, so an
|
||||
// early return drops the other and errors surface in the same order.
|
||||
//
|
||||
// The subdomain row's `associated_app_id` column is intentionally
|
||||
// not consulted — it was previously user-writable without an
|
||||
// ownership check (so any value there is either legacy or planted).
|
||||
// Resolve "what app does this subdomain host?" directly from
|
||||
// `apps.owner_user_id = site.user_id` + `index_url` match against
|
||||
// the request host.
|
||||
const appLookup = awaitLater(
|
||||
resolveOwnedAppForHostedSite({
|
||||
req,
|
||||
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 =
|
||||
site.root_dir_id === null || site.root_dir_id === undefined
|
||||
? null
|
||||
: awaitLater(
|
||||
layers.stores.fsEntry.getEntryById(site.root_dir_id),
|
||||
);
|
||||
|
||||
const associatedApp = (await appLookup) as AppRow | null;
|
||||
const associatedApp = (await resolveOwnedAppForHostedSite({
|
||||
req,
|
||||
site: { user_id: site.user_id },
|
||||
db: layers.clients.db,
|
||||
config: hostingCfg,
|
||||
})) as AppRow | null;
|
||||
|
||||
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 +419,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)
|
||||
@@ -567,7 +495,9 @@ export const createPuterSiteMiddleware = (
|
||||
return;
|
||||
}
|
||||
|
||||
const rootEntry = await rootLookup;
|
||||
const rootEntry = await layers.stores.fsEntry.getEntryById(
|
||||
site.root_dir_id,
|
||||
);
|
||||
if (!rootEntry) {
|
||||
res.status(404)
|
||||
.type('text/html; charset=UTF-8')
|
||||
@@ -649,6 +579,24 @@ export const createPuterSiteMiddleware = (
|
||||
}
|
||||
const filePath = rootPath + resolvedUrlPath;
|
||||
|
||||
// Best-effort site config load. A missing / malformed config
|
||||
// never blocks the request — `loadSiteConfig` swallows all
|
||||
// errors and returns null on any failure. The Redis cache
|
||||
// keyed on `rootDirId` keeps the hot path off S3 for the
|
||||
// common case where the same subdomain gets repeat visits.
|
||||
let siteConfig: SiteConfig | null = null;
|
||||
try {
|
||||
siteConfig = await loadSiteConfig({
|
||||
rootPath,
|
||||
rootDirId: rootEntry.id,
|
||||
fsEntryStore: layers.stores.fsEntry,
|
||||
fsService: layers.services.fs,
|
||||
cache: layers.clients.redis,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[puter-site] loadSiteConfig threw', e);
|
||||
}
|
||||
|
||||
// Hide the config file from public serving — visitors should
|
||||
// see the same 404 as for any other missing path so the
|
||||
// deployment shape isn't leaked.
|
||||
@@ -672,9 +620,6 @@ export const createPuterSiteMiddleware = (
|
||||
pathPosix.join(filePath, 'index.html'),
|
||||
);
|
||||
}
|
||||
if (entry && !isServableEntryPath(entry.path, rootPath)) {
|
||||
entry = null;
|
||||
}
|
||||
|
||||
// Custom error page fallback. On a 404 we consult the site config
|
||||
// for a rule and, if one resolves to an existing file, serve that
|
||||
@@ -684,21 +629,6 @@ export const createPuterSiteMiddleware = (
|
||||
// default 404 page rather than looping.
|
||||
let statusOverride: number | undefined;
|
||||
if (!entry || entry.isDir) {
|
||||
// Best-effort site config load, only needed on a 404. A missing /
|
||||
// malformed config never blocks the request — `loadSiteConfig`
|
||||
// returns null on any failure, and caches by `rootDirId`.
|
||||
let siteConfig: SiteConfig | null = null;
|
||||
try {
|
||||
siteConfig = await loadSiteConfig({
|
||||
rootPath,
|
||||
rootDirId: rootEntry.id,
|
||||
fsEntryStore: layers.stores.fsEntry,
|
||||
fsService: layers.services.fs,
|
||||
cache: layers.clients.redis,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[puter-site] loadSiteConfig threw', e);
|
||||
}
|
||||
const errorTarget = resolveErrorTarget(siteConfig, 404, rootPath);
|
||||
// A config pointing `errors.404.file` into `__workers/` would
|
||||
// publish worker source through the error page — refuse it the
|
||||
@@ -710,11 +640,7 @@ export const createPuterSiteMiddleware = (
|
||||
const candidate = await layers.stores.fsEntry.getEntryByPath(
|
||||
errorTarget.absPath,
|
||||
);
|
||||
if (
|
||||
candidate &&
|
||||
!candidate.isDir &&
|
||||
isServableEntryPath(candidate.path, rootPath)
|
||||
) {
|
||||
if (candidate && !candidate.isDir) {
|
||||
entry = candidate;
|
||||
statusOverride = errorTarget.status;
|
||||
}
|
||||
|
||||
@@ -100,14 +100,12 @@ const makeUserWithPassword = async (
|
||||
}> = {},
|
||||
) => {
|
||||
const hash = await bcrypt.hash(plainPassword, 4);
|
||||
const username =
|
||||
extra.username ?? `up-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const username = extra.username ?? `up-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await userStore.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: hash,
|
||||
email:
|
||||
extra.email !== undefined ? extra.email : `${username}@test.local`,
|
||||
email: extra.email !== undefined ? extra.email : `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
} as Parameters<UserStore['create']>[0]);
|
||||
@@ -146,7 +144,7 @@ describe('userProtected — requireSessionCookie (step 1)', () => {
|
||||
expect(arg).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes through when the cookie is present and req.token is undefined (no probe-attached token)', async () => {
|
||||
it("passes through when the cookie is present and req.token is undefined (no probe-attached token)", async () => {
|
||||
// This covers test-only / bypass paths where the cookie is the
|
||||
// only credential. The guard only fires when `req.token` is set
|
||||
// AND differs from the cookie.
|
||||
@@ -179,7 +177,7 @@ describe('userProtected — requireSessionCookie (step 1)', () => {
|
||||
expect((arg as HttpError).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('honors a custom config.cookie_name', async () => {
|
||||
it("honors a custom config.cookie_name", async () => {
|
||||
const gates = createUserProtectedGate({
|
||||
config: {
|
||||
cookie_name: 'custom_session',
|
||||
@@ -212,9 +210,7 @@ describe('userProtected — refreshUser (step 2)', () => {
|
||||
);
|
||||
|
||||
const [, refreshUser] = buildGate();
|
||||
const req: Partial<Request> = {
|
||||
actor: { user: { id: user.id, uuid: user.uuid } },
|
||||
};
|
||||
const req: Partial<Request> = { actor: { user: { id: user.id, uuid: user.uuid } } };
|
||||
const arg = await run(refreshUser, req);
|
||||
expect(isHttpError(arg)).toBe(true);
|
||||
expect((arg as HttpError).statusCode).toBe(403);
|
||||
@@ -224,15 +220,13 @@ describe('userProtected — refreshUser (step 2)', () => {
|
||||
it('passes through and stashes the fresh row on req.userProtected', async () => {
|
||||
const user = await makeUserWithPassword('hunter2');
|
||||
const [, refreshUser] = buildGate();
|
||||
const req: Partial<Request> = {
|
||||
actor: { user: { id: user.id, uuid: user.uuid } },
|
||||
};
|
||||
const req: Partial<Request> = { actor: { user: { id: user.id, uuid: user.uuid } } };
|
||||
const arg = await run(refreshUser, req);
|
||||
expect(arg).toBeUndefined();
|
||||
expect((req as Request).userProtected?.user.uuid).toBe(user.uuid);
|
||||
});
|
||||
|
||||
it('throws 401 when the actor lacks a user id (defensive — earlier gates should catch this)', async () => {
|
||||
it("throws 401 when the actor lacks a user id (defensive — earlier gates should catch this)", async () => {
|
||||
const [, refreshUser] = buildGate();
|
||||
const arg = await run(refreshUser, { actor: undefined });
|
||||
expect(isHttpError(arg)).toBe(true);
|
||||
@@ -272,7 +266,7 @@ describe('userProtected — verifyIdentity (step 3)', () => {
|
||||
expect(arg).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 400 password_mismatch when bcrypt says no', async () => {
|
||||
it("returns 400 password_mismatch when bcrypt says no", async () => {
|
||||
const user = await makeUserWithPassword('correct-horse');
|
||||
const [, , verifyIdentity] = buildGate();
|
||||
const arg = await run(
|
||||
@@ -284,63 +278,7 @@ describe('userProtected — verifyIdentity (step 3)', () => {
|
||||
expect((arg as HttpError).legacyCode).toBe('password_mismatch');
|
||||
});
|
||||
|
||||
it('stops guessing after ten wrong passwords on the same account', async () => {
|
||||
const user = await makeUserWithPassword('correct-horse');
|
||||
const [, , verifyIdentity] = buildGate();
|
||||
const guess = () =>
|
||||
run(
|
||||
verifyIdentity,
|
||||
withUser(user, { body: { password: 'wrong-guess' } }),
|
||||
);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect((((await guess()) as HttpError) ?? {}).legacyCode).toBe(
|
||||
'password_mismatch',
|
||||
);
|
||||
}
|
||||
const refused = (await guess()) as HttpError;
|
||||
expect(refused.statusCode).toBe(429);
|
||||
expect(refused.legacyCode).toBe('too_many_requests');
|
||||
});
|
||||
|
||||
it('bounds attempts rather than rounds, so a burst cannot outrun it', async () => {
|
||||
const user = await makeUserWithPassword('correct-horse');
|
||||
const [, , verifyIdentity] = buildGate();
|
||||
const guess = () =>
|
||||
run(
|
||||
verifyIdentity,
|
||||
withUser(user, { body: { password: 'wrong-guess' } }),
|
||||
);
|
||||
|
||||
// All at once: the budget is spent before any comparison runs, so
|
||||
// the same ten get through whether they arrive in step or together.
|
||||
const answers = (await Promise.all(
|
||||
Array.from({ length: 30 }, guess),
|
||||
)) as HttpError[];
|
||||
const compared = answers.filter(
|
||||
(a) => a.legacyCode === 'password_mismatch',
|
||||
);
|
||||
expect(compared).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('keeps an exhausted budget on one route away from the others', async () => {
|
||||
const user = await makeUserWithPassword('correct-horse');
|
||||
const [, , verifyIdentity] = buildGate();
|
||||
const on = (path: string, password: string) =>
|
||||
run(verifyIdentity, {
|
||||
...withUser(user, { body: { password } }),
|
||||
route: { path },
|
||||
} as Partial<Request>);
|
||||
|
||||
for (let i = 0; i < 10; i++) await on('/a', 'wrong-guess');
|
||||
expect(((await on('/a', 'wrong-guess')) as HttpError).statusCode).toBe(
|
||||
429,
|
||||
);
|
||||
// A different route behind the same gate still answers.
|
||||
expect(await on('/b', 'correct-horse')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 403 password_required when password account submits no credentials', async () => {
|
||||
it("returns 403 password_required when password account submits no credentials", async () => {
|
||||
// No password in body, no revalidation cookie → reject.
|
||||
const user = await makeUserWithPassword('hunter2');
|
||||
const [, , verifyIdentity] = buildGate();
|
||||
@@ -350,7 +288,7 @@ describe('userProtected — verifyIdentity (step 3)', () => {
|
||||
expect((arg as HttpError).legacyCode).toBe('password_required');
|
||||
});
|
||||
|
||||
it('accepts a valid puter_revalidation cookie in lieu of a password', async () => {
|
||||
it("accepts a valid puter_revalidation cookie in lieu of a password", async () => {
|
||||
const user = await makeUserWithPassword('hunter2');
|
||||
// Sign a real revalidation token via the real TokenService.
|
||||
const cookieValue = tokenService.sign('oidc-state', {
|
||||
@@ -409,7 +347,7 @@ describe('userProtected — verifyIdentity (step 3)', () => {
|
||||
expect((arg as HttpError).legacyCode).toBe('password_required');
|
||||
});
|
||||
|
||||
it('falls through silently when the revalidation cookie is unparseable (bad signature)', async () => {
|
||||
it("falls through silently when the revalidation cookie is unparseable (bad signature)", async () => {
|
||||
const user = await makeUserWithPassword('pwd');
|
||||
const [, , verifyIdentity] = buildGate();
|
||||
const arg = await run(
|
||||
@@ -477,12 +415,10 @@ describe('userProtected — OIDC-only accounts', () => {
|
||||
});
|
||||
expect(isHttpError(arg)).toBe(true);
|
||||
expect((arg as HttpError).statusCode).toBe(403);
|
||||
expect((arg as HttpError).legacyCode).toBe(
|
||||
'oidc_revalidation_required',
|
||||
);
|
||||
expect((arg as HttpError).legacyCode).toBe('oidc_revalidation_required');
|
||||
});
|
||||
|
||||
it('returns oidc_revalidation_required when a password-less user submits no credentials', async () => {
|
||||
it("returns oidc_revalidation_required when a password-less user submits no credentials", async () => {
|
||||
const user = await makeUserWithPassword('seed-then-null');
|
||||
await server.clients.db.write(
|
||||
'UPDATE user SET password = NULL WHERE id = ?',
|
||||
@@ -498,8 +434,6 @@ describe('userProtected — OIDC-only accounts', () => {
|
||||
cookies: {},
|
||||
});
|
||||
expect(isHttpError(arg)).toBe(true);
|
||||
expect((arg as HttpError).legacyCode).toBe(
|
||||
'oidc_revalidation_required',
|
||||
);
|
||||
expect((arg as HttpError).legacyCode).toBe('oidc_revalidation_required');
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,6 @@ import type { Request, RequestHandler, Response, NextFunction } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { isPlainUserActor } from '../../actor';
|
||||
import { HttpError } from '../HttpError';
|
||||
import { checkRateLimit } from './rateLimit.js';
|
||||
import type { IConfig } from '../../../types';
|
||||
import type { UserStore, UserRow } from '../../../stores/user/UserStore';
|
||||
import type { OIDCService } from '../../../services/auth/OIDCService';
|
||||
@@ -49,9 +48,7 @@ import type { TokenService } from '../../../services/auth/TokenService';
|
||||
* cookie (signed via `services.token.sign('oidc-state')`) is required.
|
||||
* OIDC-only accounts (no password) MUST use the revalidation cookie —
|
||||
* password path returns `oidc_revalidation_required` with a `revalidate_url`
|
||||
* so the GUI can open the OIDC popup. Each password check is charged to a
|
||||
* per-account, per-route hourly budget, so a route behind this gate is
|
||||
* bounded whether or not it declares a limit of its own.
|
||||
* so the GUI can open the OIDC popup.
|
||||
*/
|
||||
|
||||
const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
|
||||
@@ -141,11 +138,6 @@ async function buildRevalidateFields(
|
||||
};
|
||||
}
|
||||
|
||||
/** Password checks per account per route per hour, as the siblings bound. */
|
||||
const PASSWORD_ATTEMPT_LIMIT = 10;
|
||||
const PASSWORD_ATTEMPT_WINDOW_MS = 60 * 60_000;
|
||||
const PASSWORD_ATTEMPT_SCOPE = 'user-protected-password';
|
||||
|
||||
export const createUserProtectedGate = (
|
||||
deps: UserProtectedGateDeps,
|
||||
options: UserProtectedGateOptions = {},
|
||||
@@ -227,23 +219,6 @@ export const createUserProtectedGate = (
|
||||
fields,
|
||||
});
|
||||
}
|
||||
// Charged before the comparison, or everything already in
|
||||
// flight is answered before any of it is spent. Per route, so
|
||||
// one exhausted budget cannot close the other five.
|
||||
const budgetKey =
|
||||
`${PASSWORD_ATTEMPT_SCOPE}:${req.route?.path ?? req.path}` +
|
||||
`:${user.id}`;
|
||||
if (
|
||||
!(await checkRateLimit(
|
||||
budgetKey,
|
||||
PASSWORD_ATTEMPT_LIMIT,
|
||||
PASSWORD_ATTEMPT_WINDOW_MS,
|
||||
))
|
||||
) {
|
||||
throw new HttpError(429, 'Too many requests.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
}
|
||||
let match = false;
|
||||
try {
|
||||
match = await bcrypt.compare(
|
||||
|
||||
@@ -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 type { Response } from 'express';
|
||||
import http from 'node:http';
|
||||
import { Socket, type AddressInfo } from 'node:net';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { pipeToResponse } from './pipeToResponse.js';
|
||||
|
||||
interface Served {
|
||||
res: http.ServerResponse;
|
||||
piped: Promise<void>;
|
||||
closeListeners: { before: number; after: number };
|
||||
}
|
||||
|
||||
/** One request against a server that pipes `makeBody()` into its response. */
|
||||
const serve = (makeBody: () => Readable) => {
|
||||
let onServed!: (served: Served) => void;
|
||||
const served = new Promise<Served>((resolve) => {
|
||||
onServed = resolve;
|
||||
});
|
||||
const server = http.createServer((_req, res) => {
|
||||
const before = res.listenerCount('close');
|
||||
const piped = pipeToResponse(makeBody(), res as unknown as Response);
|
||||
onServed({
|
||||
res,
|
||||
piped,
|
||||
closeListeners: { before, after: res.listenerCount('close') },
|
||||
});
|
||||
});
|
||||
servers.push(server);
|
||||
const listening = new Promise<number>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () =>
|
||||
resolve((server.address() as AddressInfo).port),
|
||||
);
|
||||
});
|
||||
return { served, listening };
|
||||
};
|
||||
|
||||
const servers: http.Server[] = [];
|
||||
afterEach(async () => {
|
||||
for (const server of servers.splice(0)) {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
describe('pipeToResponse', () => {
|
||||
it('streams the body byte for byte and resolves once the response is done', async () => {
|
||||
const { served, listening } = serve(() =>
|
||||
Readable.from([Buffer.from('a'), Buffer.from('bc')]),
|
||||
);
|
||||
const response = await fetch(`http://127.0.0.1:${await listening}/`);
|
||||
expect(await response.text()).toBe('abc');
|
||||
const { res, piped } = await served;
|
||||
await piped;
|
||||
expect(res.writableFinished).toBe(true);
|
||||
});
|
||||
|
||||
it("adds no 'close' listener beyond the one pipe adds", async () => {
|
||||
const { served, listening } = serve(() => Readable.from(['x']));
|
||||
const response = await fetch(`http://127.0.0.1:${await listening}/`);
|
||||
await response.text();
|
||||
const { closeListeners } = await served;
|
||||
expect(closeListeners.after - closeListeners.before).toBe(1);
|
||||
});
|
||||
|
||||
it('releases the body when the client hangs up mid-stream', async () => {
|
||||
const body = new PassThrough();
|
||||
const { served, listening } = serve(() => body);
|
||||
const abort = new AbortController();
|
||||
const pending = fetch(`http://127.0.0.1:${await listening}/`, {
|
||||
signal: abort.signal,
|
||||
});
|
||||
body.write('first');
|
||||
const response = await pending;
|
||||
await response.body!.getReader().read();
|
||||
abort.abort();
|
||||
|
||||
const { piped } = await served;
|
||||
await piped;
|
||||
expect(body.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('cuts the response off when the body fails mid-stream', async () => {
|
||||
const body = new PassThrough();
|
||||
const { served, listening } = serve(() => body);
|
||||
const pending = fetch(`http://127.0.0.1:${await listening}/`);
|
||||
body.write('partial');
|
||||
const response = await pending;
|
||||
const reader = response.body!.getReader();
|
||||
await reader.read();
|
||||
body.destroy(new Error('upstream broke'));
|
||||
|
||||
const outcome = await (async () => {
|
||||
try {
|
||||
while (!(await reader.read()).done);
|
||||
return 'ended';
|
||||
} catch {
|
||||
return 'cut off';
|
||||
}
|
||||
})();
|
||||
expect(outcome).toBe('cut off');
|
||||
const { res, piped } = await served;
|
||||
await piped;
|
||||
expect(res.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('releases the body at once when the response already closed', async () => {
|
||||
const res = new http.ServerResponse(
|
||||
new http.IncomingMessage(new Socket()),
|
||||
);
|
||||
res.destroy();
|
||||
const body = new PassThrough();
|
||||
await pipeToResponse(body, res as unknown as Response);
|
||||
expect(body.destroyed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +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 { Response } from 'express';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
/**
|
||||
* Streams `body` into the response. A body that fails mid-stream cuts the
|
||||
* response off, and a response that closes first releases the body. Resolves
|
||||
* once the response is done with the body; never rejects.
|
||||
*/
|
||||
export const pipeToResponse = (
|
||||
body: Readable,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
body.on('error', (err) => res.destroy(err));
|
||||
if (res.destroyed) {
|
||||
body.destroy();
|
||||
resolve();
|
||||
return promise;
|
||||
}
|
||||
// `pipe` unpipes once the response finishes, closes or fails, so this
|
||||
// needs no 'close' listener of its own on top of the one `pipe` adds.
|
||||
res.once('unpipe', () => {
|
||||
if (!body.readableEnded) body.destroy();
|
||||
resolve();
|
||||
});
|
||||
body.pipe(res);
|
||||
return promise;
|
||||
};
|
||||
@@ -28,12 +28,7 @@ import type { Actor } from '../actor';
|
||||
* it instead of accepting any token that authenticates.
|
||||
*/
|
||||
export type TokenSource =
|
||||
| 'body'
|
||||
| 'header'
|
||||
| 'x-api-key'
|
||||
| 'cookie'
|
||||
| 'query'
|
||||
| 'handshake';
|
||||
'body' | 'header' | 'x-api-key' | 'cookie' | 'query' | 'handshake';
|
||||
|
||||
/** Express router methods plus the WebDAV verbs some endpoints use. */
|
||||
export type RouteMethod =
|
||||
@@ -339,9 +334,6 @@ export const ROUTES_METADATA_KEY = '__puterRoutes' as const;
|
||||
/** Internal: the property name used to stash a controller's path prefix. */
|
||||
export const PREFIX_METADATA_KEY = '__puterControllerPrefix' as const;
|
||||
|
||||
/** How long a browser may cache a CORS preflight reply, in seconds. */
|
||||
export const PREFLIGHT_MAX_AGE = '86400';
|
||||
|
||||
// -- Type narrowing helpers ------------------------------------------
|
||||
//
|
||||
// When a route declares a gate option (requireAuth, requireUserActor,
|
||||
@@ -367,8 +359,7 @@ type AuthImplyingOptions =
|
||||
| { requireCardVerified: true }
|
||||
| {
|
||||
requireAnyVerified:
|
||||
| readonly VerificationFactor[]
|
||||
| VerificationFactor[];
|
||||
readonly VerificationFactor[] | VerificationFactor[];
|
||||
}
|
||||
| { requireSubscription: true | readonly string[] | string[] }
|
||||
| { requireReputation: string };
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,199 +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 { createServer, type Server } from 'node:http';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
import { clearUnhealthyRoutes } from './utils/providerHealth.js';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
|
||||
const policyMessage =
|
||||
'Policy Violation: this user has been blocked for a previous policy violation. Learn more: https://vendor.test/private';
|
||||
const routes = [
|
||||
'/drivers/call',
|
||||
'/puterai/openai/v1/chat/completions',
|
||||
'/puterai/anthropic/v1/messages',
|
||||
];
|
||||
const cases = [
|
||||
{
|
||||
name: 'policy block',
|
||||
status: 403,
|
||||
body: { error: { code: 403, message: policyMessage } },
|
||||
expected: [400, 404, 404],
|
||||
},
|
||||
{
|
||||
name: 'in-band policy block',
|
||||
status: 200,
|
||||
body: { error: { code: 403, message: policyMessage } },
|
||||
expected: [400, 404, 404],
|
||||
},
|
||||
{
|
||||
name: 'bad credentials',
|
||||
status: 401,
|
||||
body: { error: { code: 401, message: 'Invalid API key' } },
|
||||
expected: [500, 500, 500],
|
||||
},
|
||||
{
|
||||
name: 'generic forbidden',
|
||||
status: 403,
|
||||
body: { error: { code: 403, message: 'Forbidden' } },
|
||||
expected: [500, 500, 500],
|
||||
},
|
||||
{
|
||||
name: 'credit exhaustion',
|
||||
status: 403,
|
||||
body: {
|
||||
error: {
|
||||
code: 'insufficient_user_quota',
|
||||
message: 'user quota is not enough',
|
||||
},
|
||||
},
|
||||
expected: [503, 503, 503],
|
||||
},
|
||||
{
|
||||
name: 'outage',
|
||||
status: 503,
|
||||
body: { error: { code: 503, message: 'service unavailable' } },
|
||||
expected: [400, 400, 502],
|
||||
},
|
||||
{
|
||||
name: 'empty choices',
|
||||
status: 200,
|
||||
body: { choices: [] },
|
||||
expected: [400, 400, 400],
|
||||
},
|
||||
];
|
||||
|
||||
describe('model availability through real HTTP', () => {
|
||||
let upstream: Server;
|
||||
let env: PuterTestEnv;
|
||||
let response = cases[0];
|
||||
beforeAll(async () => {
|
||||
upstream = createServer((req, res) => {
|
||||
res.setHeader('content-type', 'application/json');
|
||||
if (req.url === '/v1/models') {
|
||||
const model = {
|
||||
id: 'vendor/availability-test',
|
||||
name: 'Availability test',
|
||||
context_length: 4096,
|
||||
top_provider: { max_completion_tokens: 256 },
|
||||
pricing: { prompt: 0.000001, completion: 0.000001 },
|
||||
};
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
model,
|
||||
{
|
||||
...model,
|
||||
id: `${model.id}:batch`,
|
||||
name: 'Batch only',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
req.resume();
|
||||
req.on('end', () => {
|
||||
res.statusCode = response.status;
|
||||
res.end(JSON.stringify(response.body));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
upstream.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
const port = (upstream.address() as { port: number }).port;
|
||||
env = await setupPuterTestEnv({
|
||||
providers: {
|
||||
openrouter: {
|
||||
apiKey: 'synthetic-key',
|
||||
apiBaseUrl: `http://127.0.0.1:${port}/v1`,
|
||||
},
|
||||
},
|
||||
meteringEnforcement: { subscriptions: false },
|
||||
} as never);
|
||||
}, 120000);
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
upstream?.closeAllConnections();
|
||||
if (upstream)
|
||||
await new Promise<void>((resolve) =>
|
||||
upstream.close(() => resolve()),
|
||||
);
|
||||
});
|
||||
beforeEach(() => clearUnhealthyRoutes());
|
||||
|
||||
it('excludes batch IDs and aliases', async () => {
|
||||
const models = await env.server.drivers.aiChat.models();
|
||||
expect(
|
||||
models.some((m) => m.id === 'openrouter:vendor/availability-test'),
|
||||
).toBe(true);
|
||||
expect(JSON.stringify(models)).not.toContain(':batch');
|
||||
});
|
||||
for (const [index, route] of routes.entries()) {
|
||||
it.each(cases)(
|
||||
`$name on ${route}`,
|
||||
async (current) => {
|
||||
response = current;
|
||||
const testUser = index === 2 ? env.users.other : env.users.user;
|
||||
const args = {
|
||||
model: 'openrouter:vendor/availability-test',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 16,
|
||||
stream: false,
|
||||
};
|
||||
const res = await fetch(new URL(route, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${testUser.apiToken}`,
|
||||
Origin: env.apiOrigin,
|
||||
},
|
||||
body: JSON.stringify(
|
||||
route === '/drivers/call'
|
||||
? {
|
||||
interface: 'puter-chat-completion',
|
||||
service: 'ai-chat',
|
||||
method: 'complete',
|
||||
args,
|
||||
}
|
||||
: args,
|
||||
),
|
||||
});
|
||||
const body = await res.text();
|
||||
expect(res.status, body).toBe(current.expected[index]);
|
||||
if (current.name.includes('policy block')) {
|
||||
expect(body).toMatch(/model.*(not available|unavailable)/i);
|
||||
expect(body).not.toMatch(
|
||||
/vendor\.test|policy violation|private/i,
|
||||
);
|
||||
}
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
testUser.username,
|
||||
);
|
||||
expect(
|
||||
await env.server.services.metering.getOutstandingHolds(
|
||||
makeActor({ user: user! }),
|
||||
),
|
||||
).toBe(0);
|
||||
},
|
||||
30000,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -55,8 +55,6 @@ import { NeuralwattProvider } from './providers/neuralwatt/NeuralwattProvider.js
|
||||
import { OpenAiChatProvider } from './providers/openai/OpenAiChatCompletionsProvider.js';
|
||||
import { OpenRouterProvider } from './providers/openrouter/OpenRouterProvider.js';
|
||||
import { TogetherAIProvider } from './providers/together/TogetherAIProvider.js';
|
||||
import { XAI_CHAT_MODELS } from './providers/xai/catalog.js';
|
||||
import { XAIProvider } from './providers/xai/XAIProvider.js';
|
||||
import { handle_completion_output } from './utils/OpenAIUtil.js';
|
||||
import { clearUnhealthyRoutes, isRouteUnhealthy } from './utils/providerHealth.js';
|
||||
|
||||
@@ -129,10 +127,6 @@ beforeAll(async () => {
|
||||
vi.spyOn(NeuralwattProvider.prototype, 'models').mockResolvedValue(
|
||||
aggregatorCatalog('neuralwatt-only-model') as never,
|
||||
);
|
||||
// xAI reprices its curated catalog over HTTP; serve the curated one.
|
||||
vi.spyOn(XAIProvider.prototype, 'models').mockResolvedValue(
|
||||
XAI_CHAT_MODELS,
|
||||
);
|
||||
fullDriver = await makeDriver(FULL_PROVIDER_CONFIG);
|
||||
fakeOnlyDriver = await makeDriver({
|
||||
providers: { ollama: { enabled: false } },
|
||||
@@ -537,82 +531,6 @@ describe('ChatCompletionDriver exhausted-chain classification', () => {
|
||||
legacyCode: 'upstream_bad_request',
|
||||
fields: expect.objectContaining({ upstreamStatus: 404 }),
|
||||
});
|
||||
expect(err.alarmSeverity).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps an upstream 403 model_not_found to the model-unavailable 400, not an auth failure, and redacts its URL', async () => {
|
||||
const err = await errorFor({
|
||||
status: 403,
|
||||
message:
|
||||
'The model `fake` is not available to your account. See https://vendor.test/models (request id: req-secret)',
|
||||
error: {
|
||||
code: 'model_not_found',
|
||||
message: 'The model `fake` is not available to your account.',
|
||||
},
|
||||
});
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_bad_request',
|
||||
fields: expect.objectContaining({ upstreamStatus: 404 }),
|
||||
});
|
||||
expect(err.noAlarm).toBeFalsy();
|
||||
expect(err.alarmSeverity).toBe('info');
|
||||
expect(err.message).toBe('Model not available: fake');
|
||||
expect(JSON.stringify(attemptsFrom(err))).not.toMatch(
|
||||
/http|request id/i,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no code', {}],
|
||||
['another code', { error: { code: 'access_denied' } }],
|
||||
])(
|
||||
'keeps a 403 with %s as 500 upstream_auth_failed',
|
||||
async (_label, extra) => {
|
||||
const err = await errorFor({
|
||||
status: 403,
|
||||
message: 'Forbidden',
|
||||
...extra,
|
||||
});
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 500,
|
||||
legacyCode: 'upstream_auth_failed',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a 404 whose message reads like a credential failure an auth failure', async () => {
|
||||
const err = await errorFor(
|
||||
Object.assign(new Error('Unauthorized'), { status: 404 }),
|
||||
);
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 500,
|
||||
legacyCode: 'upstream_auth_failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a 401 model_not_found an auth failure', async () => {
|
||||
const err = await errorFor({
|
||||
status: 401,
|
||||
message: 'invalid api key',
|
||||
error: { code: 'model_not_found' },
|
||||
});
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 500,
|
||||
legacyCode: 'upstream_auth_failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies a 403 insufficient_user_quota as credit exhaustion', async () => {
|
||||
const err = await errorFor({
|
||||
status: 403,
|
||||
message: 'user quota is not enough',
|
||||
error: { code: 'insufficient_user_quota' },
|
||||
});
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 503,
|
||||
legacyCode: 'upstream_credits_exhausted',
|
||||
});
|
||||
});
|
||||
|
||||
it('sniffs a status out of the message when the provider throws a bare Error', async () => {
|
||||
@@ -633,40 +551,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 +727,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'), {
|
||||
@@ -1105,9 +969,6 @@ describe('ChatCompletionDriver output cap on xAI', () => {
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({ balance: 360_000, held: 0 });
|
||||
vi.spyOn(XAIProvider.prototype, 'models').mockResolvedValue(
|
||||
XAI_CHAT_MODELS,
|
||||
);
|
||||
const create = vi
|
||||
.spyOn(OpenAI.Chat.Completions.prototype, 'create')
|
||||
.mockResolvedValue(chatCompletion('hi') as never);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1106,129 +993,3 @@ describe('stream pump termination', () => {
|
||||
expect(warning).toHaveBeenCalledWith('Chat stream cleanup failed:', 'cleanup failed');
|
||||
});
|
||||
});
|
||||
|
||||
// A list-price change can land while a completion is running. The attempt is
|
||||
// held, metered and estimated at the rates in force when it started, so the
|
||||
// hold covers what is charged and the charge doesn't jump mid-request.
|
||||
describe('ChatCompletionDriver pricing across a scheduled rate change', () => {
|
||||
const REPRICED_MODEL = {
|
||||
...PRICED_MODEL,
|
||||
id: 'repriced',
|
||||
pricingOverrides: [
|
||||
{
|
||||
startsAt: '2026-12-01T00:00:00Z',
|
||||
costs: { input_tokens: 2000, output_tokens: 4000 },
|
||||
},
|
||||
],
|
||||
};
|
||||
const BEFORE = new Date('2026-11-30T23:59:59.000Z');
|
||||
const AFTER = new Date('2026-12-01T00:00:05.000Z');
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValue([
|
||||
REPRICED_MODEL,
|
||||
PRICED_MODEL,
|
||||
] as never);
|
||||
driver = await makeDriver();
|
||||
vi.useFakeTimers({ toFake: ['Date'], now: BEFORE });
|
||||
});
|
||||
|
||||
const listedOutputRate = async (id: string) => ({
|
||||
listed: (await driver.models()).find((m) => m.id === id)!.costs
|
||||
.output_tokens,
|
||||
reported: driver
|
||||
.getReportedCosts()
|
||||
.find((c) => String(c.usageType).endsWith(`:${id}:output_tokens`))!
|
||||
.ucentsPerUnit,
|
||||
});
|
||||
|
||||
it('lists and reports the rates in force, before and after the change', async () => {
|
||||
expect(await listedOutputRate('repriced')).toEqual({
|
||||
listed: 2000,
|
||||
reported: 2000,
|
||||
});
|
||||
vi.setSystemTime(AFTER);
|
||||
expect(await listedOutputRate('repriced')).toEqual({
|
||||
listed: 4000,
|
||||
reported: 4000,
|
||||
});
|
||||
// A model with no scheduled change lists its own rates either way.
|
||||
expect(await listedOutputRate('priced')).toEqual({
|
||||
listed: 2000,
|
||||
reported: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('holds, meters and estimates an in-flight stream at its start instant', async () => {
|
||||
const actor = freeUser() as never;
|
||||
const reserve = vi.spyOn(server.services.metering, 'reserveCredits');
|
||||
const metered = vi.spyOn(
|
||||
server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
);
|
||||
const completeSpy = vi
|
||||
.spyOn(FakeChatProvider.prototype, 'complete')
|
||||
.mockImplementationOnce(async () => {
|
||||
vi.setSystemTime(AFTER);
|
||||
return streamOf(async ({ chatStream }) => {
|
||||
chatStream
|
||||
.message()
|
||||
.contentBlock({ type: 'text' })
|
||||
.addText('x'.repeat(4000));
|
||||
chatStream.end(undefined as never);
|
||||
}) as never;
|
||||
});
|
||||
|
||||
const result = (await withTestActor(
|
||||
() =>
|
||||
driver.complete({
|
||||
model: 'repriced',
|
||||
messages: [{ role: 'user', content: 'go' }],
|
||||
stream: true,
|
||||
max_tokens: 10,
|
||||
}),
|
||||
actor,
|
||||
)) as unknown as { stream: Readable };
|
||||
await drain(result.stream);
|
||||
|
||||
expect(completeSpy.mock.calls[0]![2]).toEqual(BEFORE);
|
||||
const [, held] = reserve.mock.calls[0]!;
|
||||
expect(held).toBeGreaterThanOrEqual(10 * 2000);
|
||||
expect(held).toBeLessThan(10 * 4000);
|
||||
const output = meteredUsages(metered).find((u) =>
|
||||
u.usageType.endsWith('estimated_output_tokens'),
|
||||
)!;
|
||||
// 4000 characters at 4 chars/token, at the launch output rate.
|
||||
expect(output.costOverride).toBe(1000 * 2000);
|
||||
});
|
||||
|
||||
it('prices an attempt that starts after the change at the new rates', async () => {
|
||||
vi.setSystemTime(AFTER);
|
||||
const reserve = vi.spyOn(server.services.metering, 'reserveCredits');
|
||||
const completeSpy = vi
|
||||
.spyOn(FakeChatProvider.prototype, 'complete')
|
||||
.mockResolvedValueOnce({
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
finish_reason: 'stop',
|
||||
} as never);
|
||||
|
||||
await withTestActor(
|
||||
() =>
|
||||
driver.complete({
|
||||
model: 'repriced',
|
||||
messages: [{ role: 'user', content: 'go' }],
|
||||
max_tokens: 10,
|
||||
}),
|
||||
freeUser() as never,
|
||||
);
|
||||
|
||||
expect(completeSpy.mock.calls[0]![2]).toEqual(AFTER);
|
||||
const [, held] = reserve.mock.calls[0]!;
|
||||
expect(held).toBeGreaterThanOrEqual(10 * 4000);
|
||||
});
|
||||
});
|
||||
@@ -40,12 +40,6 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import type { Request } from 'express';
|
||||
import {
|
||||
renderAnthropicError,
|
||||
renderOpenAIError,
|
||||
} from '../../controllers/puterai/wireErrors.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
@@ -58,7 +52,6 @@ import {
|
||||
markRouteUnhealthy,
|
||||
} from './utils/providerHealth.js';
|
||||
import { InfronProvider } from './providers/infron/InfronProvider.js';
|
||||
import { UpstreamDeadlineError } from '../util/upstreamErrors.js';
|
||||
|
||||
// -- OpenAI SDK mock ------------------------------------------------
|
||||
// Gemini reaches Google through `new openai.OpenAI()` (default export) and
|
||||
@@ -150,7 +143,6 @@ const OPENROUTER_CATALOG = [
|
||||
// also the alias the Meta provider publishes — the two have to land in
|
||||
// one bucket rather than becoming separate models.
|
||||
'meta/muse-spark-1.2',
|
||||
'openai/o1-pro',
|
||||
].map((id) => ({
|
||||
id,
|
||||
name: `${id} (via OpenRouter)`,
|
||||
@@ -483,366 +475,6 @@ describe('ChatCompletionDriver muse spark routing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatCompletionDriver model-unavailable routes', () => {
|
||||
// Shaped like the OpenAI SDK's APIError for an OpenAI-convention body.
|
||||
const modelNotFound = () =>
|
||||
Object.assign(
|
||||
new Error('403 The model is not available to your account.'),
|
||||
{
|
||||
status: 403,
|
||||
error: {
|
||||
code: 'model_not_found',
|
||||
message: 'The model is not available to your account.',
|
||||
},
|
||||
},
|
||||
);
|
||||
const statusError = (status: number, message: string) =>
|
||||
Object.assign(new Error(message), { status });
|
||||
const reply = {
|
||||
choices: [
|
||||
{
|
||||
message: { role: 'assistant', content: 'hi' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
};
|
||||
|
||||
const complete = (model: string, provider?: string) =>
|
||||
withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
...(provider ? { provider } : {}),
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
).catch((e: unknown) => e as HttpError);
|
||||
|
||||
const statusesOf = (err: HttpError) =>
|
||||
(
|
||||
err as unknown as { fields: { attempts: { status?: number }[] } }
|
||||
).fields.attempts.map((a) => a.status);
|
||||
|
||||
it('falls back past a gated route and marks only that route', async () => {
|
||||
createMock
|
||||
.mockRejectedValueOnce(modelNotFound())
|
||||
.mockResolvedValueOnce(reply);
|
||||
|
||||
await expect(complete('deepseek-v4-pro')).resolves.toMatchObject({
|
||||
message: { content: 'hi' },
|
||||
});
|
||||
expect(isRouteUnhealthy('deepseek', 'deepseek-v4-pro')).toBe(true);
|
||||
expect(
|
||||
isRouteUnhealthy('infron', 'infron:deepseek/deepseek-v4-pro'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps serving a model from its vendor after the gateway route is gated', async () => {
|
||||
createMock
|
||||
.mockRejectedValueOnce(modelNotFound())
|
||||
.mockResolvedValueOnce(reply)
|
||||
.mockResolvedValueOnce(reply);
|
||||
|
||||
await expect(
|
||||
complete('muse-spark-1.2', 'openrouter'),
|
||||
).resolves.toMatchObject({ message: { content: 'hi' } });
|
||||
expect(createMock.mock.calls.at(-1)![0].model).toBe('muse-spark-1.2');
|
||||
expect(
|
||||
isRouteUnhealthy('openrouter', 'openrouter:meta/muse-spark-1.2'),
|
||||
).toBe(true);
|
||||
expect(isRouteUnhealthy('meta', 'muse-spark-1.2')).toBe(false);
|
||||
|
||||
await expect(complete('meta/muse-spark-1.2')).resolves.toMatchObject({
|
||||
message: { content: 'hi' },
|
||||
});
|
||||
expect(createMock.mock.calls.at(-1)![0].model).toBe('muse-spark-1.2');
|
||||
});
|
||||
|
||||
it('keeps a gated route marked for as long as bad credentials', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const start = Date.now();
|
||||
try {
|
||||
createMock
|
||||
.mockRejectedValueOnce(modelNotFound())
|
||||
.mockResolvedValueOnce(reply);
|
||||
await complete('deepseek-v4-pro');
|
||||
|
||||
vi.setSystemTime(start + 61 * 1000);
|
||||
expect(isRouteUnhealthy('deepseek', 'deepseek-v4-pro')).toBe(true);
|
||||
vi.setSystemTime(start + (10 * 60 + 1) * 1000);
|
||||
expect(isRouteUnhealthy('deepseek', 'deepseek-v4-pro')).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('tells the caller the model is unavailable when every route is gated or missing', async () => {
|
||||
createMock
|
||||
.mockRejectedValueOnce(modelNotFound())
|
||||
.mockRejectedValue(statusError(404, 'model not found'));
|
||||
|
||||
const err = await complete('deepseek-v4-pro');
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_bad_request',
|
||||
fields: expect.objectContaining({ upstreamStatus: 404 }),
|
||||
});
|
||||
expect(statusesOf(err)).toEqual([403, 404, 404]);
|
||||
});
|
||||
|
||||
it('still pages when a gated route sits beside a bad credential', async () => {
|
||||
createMock
|
||||
.mockRejectedValueOnce(modelNotFound())
|
||||
.mockRejectedValue(statusError(401, 'invalid api key'));
|
||||
|
||||
const err = await complete('deepseek-v4-pro');
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 500,
|
||||
legacyCode: 'upstream_auth_failed',
|
||||
});
|
||||
expect(statusesOf(err)).toEqual([403, 401, 401]);
|
||||
});
|
||||
|
||||
it('classifies a gated route beside an outage as a mixed upstream failure', async () => {
|
||||
createMock
|
||||
.mockRejectedValueOnce(modelNotFound())
|
||||
.mockRejectedValue(statusError(503, 'service unavailable'));
|
||||
|
||||
const err = await complete('deepseek-v4-pro');
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_failed',
|
||||
});
|
||||
expect(statusesOf(err)).toEqual([403, 503, 503]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatCompletionDriver OpenRouter account policy block', () => {
|
||||
const POLICY_MESSAGE =
|
||||
'Policy Violation: this user has been blocked for a previous policy violation. Learn more: https://openrouter.ai/docs/policy (request id: req-secret)';
|
||||
// Shaped like the OpenAI SDK's APIError for OpenRouter's error body.
|
||||
const policyBlock = () =>
|
||||
Object.assign(new Error(`403 ${POLICY_MESSAGE}`), {
|
||||
status: 403,
|
||||
code: 403,
|
||||
error: { code: 403, message: POLICY_MESSAGE },
|
||||
});
|
||||
const reply = {
|
||||
choices: [
|
||||
{
|
||||
message: { role: 'assistant', content: 'hi' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
};
|
||||
|
||||
const complete = (model: string, provider?: string) =>
|
||||
withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
...(provider ? { provider } : {}),
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
).catch((e: unknown) => e as HttpError);
|
||||
|
||||
const attemptsOf = (err: HttpError) =>
|
||||
(
|
||||
err as unknown as {
|
||||
fields: {
|
||||
attempts: {
|
||||
provider: string;
|
||||
status?: number;
|
||||
code?: unknown;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
).fields.attempts;
|
||||
|
||||
it.each(['openai/o1-pro', 'openrouter:openai/o1-pro', 'o1-pro'])(
|
||||
'tells a caller asking for %s that the model is unavailable',
|
||||
async (model) => {
|
||||
createMock.mockRejectedValueOnce(policyBlock());
|
||||
|
||||
const err = await complete(model);
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_bad_request',
|
||||
message: 'Model not available: openrouter:openai/o1-pro',
|
||||
fields: expect.objectContaining({ upstreamStatus: 404 }),
|
||||
});
|
||||
expect(JSON.stringify(err.fields)).not.toMatch(
|
||||
/policy|http|request id/i,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('treats the in-band policy block of a 200 with no choices the same way', async () => {
|
||||
createMock.mockResolvedValueOnce({
|
||||
error: { code: 403, message: POLICY_MESSAGE },
|
||||
});
|
||||
|
||||
const err = await complete('openai/o1-pro');
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_bad_request',
|
||||
fields: expect.objectContaining({ upstreamStatus: 404 }),
|
||||
});
|
||||
expect(attemptsOf(err)).toEqual([
|
||||
expect.objectContaining({
|
||||
provider: 'openrouter',
|
||||
status: 403,
|
||||
code: 'model_not_found',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a no-choice completion without the policy block a request-level 400', async () => {
|
||||
createMock.mockResolvedValueOnce({});
|
||||
|
||||
const err = await complete('openai/o1-pro');
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_bad_request',
|
||||
message: 'no completion was returned',
|
||||
});
|
||||
expect(err.fields?.upstreamStatus).toBe(400);
|
||||
expect(isRouteUnhealthy('openrouter', 'openrouter:openai/o1-pro')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back from a policy-blocked gateway route to the vendor, under the Anthropic route too', async () => {
|
||||
for (const strict of [false, true]) {
|
||||
clearUnhealthyRoutes();
|
||||
createMock
|
||||
.mockRejectedValueOnce(policyBlock())
|
||||
.mockResolvedValueOnce(reply);
|
||||
|
||||
const res = await withTestActor(() => {
|
||||
if (strict) Context.set('strictUpstreamErrors', true);
|
||||
return driver.complete({
|
||||
model: 'muse-spark-1.2',
|
||||
provider: 'openrouter',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
});
|
||||
});
|
||||
|
||||
expect(res).toMatchObject({ message: { content: 'hi' } });
|
||||
expect(createMock.mock.calls.at(-1)![0].model).toBe(
|
||||
'muse-spark-1.2',
|
||||
);
|
||||
expect(
|
||||
isRouteUnhealthy(
|
||||
'openrouter',
|
||||
'openrouter:meta/muse-spark-1.2',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isRouteUnhealthy('meta', 'muse-spark-1.2')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders a gated route beside a missing one as not found on both wire formats', async () => {
|
||||
createMock
|
||||
.mockRejectedValueOnce(policyBlock())
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('model not found'), { status: 404 }),
|
||||
);
|
||||
|
||||
const err = await complete('muse-spark-1.2', 'openrouter');
|
||||
|
||||
expect(attemptsOf(err).map((a) => a.status)).toEqual([403, 404]);
|
||||
const anthropic = renderAnthropicError(err, {} as Request);
|
||||
const openai = renderOpenAIError(err, {} as Request);
|
||||
expect(anthropic.status).toBe(404);
|
||||
expect(anthropic.body).toMatchObject({
|
||||
error: {
|
||||
type: 'not_found_error',
|
||||
message: 'Model not available: openrouter:meta/muse-spark-1.2',
|
||||
},
|
||||
});
|
||||
expect(openai.status).toBe(404);
|
||||
expect(openai.body).toMatchObject({
|
||||
error: {
|
||||
type: 'not_found_error',
|
||||
message: 'Model not available: openrouter:meta/muse-spark-1.2',
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify([anthropic.body, openai.body])).not.toMatch(
|
||||
/policy|http|request id/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the same body from another provider an auth failure', async () => {
|
||||
createMock.mockRejectedValue(policyBlock());
|
||||
|
||||
const err = await complete('muse-spark-1.2');
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 500,
|
||||
legacyCode: 'upstream_auth_failed',
|
||||
});
|
||||
expect(attemptsOf(err)).toEqual([
|
||||
expect.objectContaining({
|
||||
provider: 'meta',
|
||||
status: 403,
|
||||
code: 403,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
provider: 'openrouter',
|
||||
status: 403,
|
||||
code: 'model_not_found',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a generic 403',
|
||||
{ status: 403, error: { code: 403, message: 'Forbidden' } },
|
||||
500,
|
||||
'upstream_auth_failed',
|
||||
],
|
||||
[
|
||||
'a 401',
|
||||
{ status: 401, error: { code: 401, message: 'User not found.' } },
|
||||
500,
|
||||
'upstream_auth_failed',
|
||||
],
|
||||
[
|
||||
'a 402',
|
||||
{
|
||||
status: 402,
|
||||
error: { code: 402, message: 'Insufficient credits' },
|
||||
},
|
||||
503,
|
||||
'upstream_credits_exhausted',
|
||||
],
|
||||
[
|
||||
'a 503',
|
||||
{ status: 503, error: { code: 503, message: POLICY_MESSAGE } },
|
||||
400,
|
||||
'upstream_provider_unavailable',
|
||||
],
|
||||
])(
|
||||
'keeps %s from OpenRouter classified as before',
|
||||
async (_label, shape, statusCode, legacyCode) => {
|
||||
createMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error(shape.error.message), shape),
|
||||
);
|
||||
|
||||
const err = await complete('openai/o1-pro');
|
||||
|
||||
expect(err).toMatchObject({ statusCode, legacyCode });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('ChatCompletionDriver duplicate-model fallback', () => {
|
||||
// deepseek-v4-pro is served directly by DeepSeek, by Infron, and twice by
|
||||
// OpenRouter (two upstream orgs) — four routes in one bucket.
|
||||
@@ -1080,25 +712,6 @@ describe('ChatCompletionDriver unhealthy-route skipping', () => {
|
||||
expect(isRouteUnhealthy('deepseek', 'deepseek-v4-pro')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 504 for a request that outlived its deadline and keeps the route', async () => {
|
||||
createMock.mockRejectedValue(
|
||||
new UpstreamDeadlineError('Gemini request timed out'),
|
||||
);
|
||||
|
||||
const err = await withTestActor(() =>
|
||||
driver.complete({
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
).catch((e: unknown) => e as HttpError);
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 504,
|
||||
legacyCode: 'upstream_timeout',
|
||||
});
|
||||
expect(isRouteUnhealthy('deepseek', 'deepseek-v4-pro')).toBe(false);
|
||||
});
|
||||
|
||||
describe('how long a failed route stays marked', () => {
|
||||
let start: number;
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -20,17 +20,19 @@
|
||||
/**
|
||||
* Offline unit tests for ChatCompletionDriver.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) so the
|
||||
* driver runs against the live `EventClient`, `MeteringService`, stores, and
|
||||
* `FSService`. The driver is exercised through its public `complete()`
|
||||
* interface against the always-available `FakeChatProvider` — no real upstream
|
||||
* API keys are needed because no `config.providers.*` entries are set, so
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) so the driver runs against the live `EventClient`,
|
||||
* `MeteringService`, stores, and `FSService`. The driver is
|
||||
* exercised through its public `complete()` interface against the
|
||||
* always-available `FakeChatProvider` — no real upstream API keys
|
||||
* are needed because no `config.providers.*` entries are set, so
|
||||
* `fake-chat` is the only provider registered.
|
||||
*
|
||||
* Provider behaviour (request shape, streaming dialect) is covered separately
|
||||
* by each provider's test file. This file pins down driver-level behaviour:
|
||||
* auth gate, model resolution, validation event routing, credit/quota gates,
|
||||
* max_tokens cap, fallback, event emission and cost calculation.
|
||||
* Provider behaviour (request shape, streaming dialect) is covered
|
||||
* separately by each provider's test file. This file pins down
|
||||
* driver-level behaviour: auth gate, model resolution, validation
|
||||
* event routing, credit/quota gates, max_tokens cap, fallback,
|
||||
* event emission and cost calculation.
|
||||
*/
|
||||
import type { Readable } from 'node:stream';
|
||||
import {
|
||||
@@ -53,8 +55,6 @@ import { COUNT_TOKENS, ChatCompletionDriver } from './ChatCompletionDriver.js';
|
||||
import { ClaudeProvider } from './providers/claude/ClaudeProvider.js';
|
||||
import { FakeChatProvider } from './providers/FakeChatProvider.js';
|
||||
import { OllamaChatProvider } from './providers/ollama/OllamaProvider.js';
|
||||
import { XAI_CHAT_MODELS } from './providers/xai/catalog.js';
|
||||
import { XAIProvider } from './providers/xai/XAIProvider.js';
|
||||
import type {
|
||||
IChatCompleteResult,
|
||||
IChatProvider,
|
||||
@@ -189,9 +189,6 @@ describe('ChatCompletionDriver model map', () => {
|
||||
);
|
||||
|
||||
it('registers a keyed provider under its name, read from its config key', async () => {
|
||||
vi.spyOn(XAIProvider.prototype, 'models').mockResolvedValue(
|
||||
XAI_CHAT_MODELS,
|
||||
);
|
||||
const d = newDriver({
|
||||
gemini: { apiKey: 'k' },
|
||||
// Registered as `moonshotai`, configured as `moonshot`.
|
||||
@@ -254,10 +251,7 @@ describe('ChatCompletionDriver model map', () => {
|
||||
|
||||
describe('ChatCompletionDriver.complete auth and model resolution', () => {
|
||||
it('uses a free fake completion in test mode without taking a credit hold', async () => {
|
||||
const creditGate = vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
);
|
||||
const creditGate = vi.spyOn(server.services.metering, 'getUsageHeadroom');
|
||||
const complete = vi.spyOn(FakeChatProvider.prototype, 'complete');
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
@@ -291,15 +285,10 @@ describe('ChatCompletionDriver.complete auth and model resolution', () => {
|
||||
);
|
||||
|
||||
expect(complete.mock.calls[0]![0].model).toBe('fake');
|
||||
const streamResult = result as unknown as {
|
||||
dataType: string;
|
||||
stream: Readable;
|
||||
};
|
||||
const streamResult = result as unknown as { dataType: string; stream: Readable };
|
||||
expect(streamResult.dataType).toBe('stream');
|
||||
const chunks = await collectStream(streamResult.stream);
|
||||
expect(chunks).toContainEqual(
|
||||
expect.objectContaining({ type: 'text' }),
|
||||
);
|
||||
expect(chunks).toContainEqual(expect.objectContaining({ type: 'text' }));
|
||||
});
|
||||
|
||||
it('throws 401 when no actor is in context', async () => {
|
||||
@@ -374,10 +363,7 @@ describe('ChatCompletionDriver.complete auth and model resolution', () => {
|
||||
|
||||
const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete');
|
||||
completeSpy.mockResolvedValueOnce({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
},
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
||||
usage: {},
|
||||
finish_reason: 'stop',
|
||||
} as never);
|
||||
@@ -626,11 +612,7 @@ describe('ChatCompletionDriver.complete events and cost emission', () => {
|
||||
id: 'priced',
|
||||
aliases: [],
|
||||
costs_currency: 'usd-cents',
|
||||
costs: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 2000,
|
||||
web_search_requests: 1_000_000,
|
||||
},
|
||||
costs: { input_tokens: 1000, output_tokens: 2000, web_search_requests: 1_000_000 },
|
||||
long_context_pricing: {
|
||||
threshold: 5,
|
||||
input_multiplier: 2,
|
||||
@@ -646,11 +628,7 @@ describe('ChatCompletionDriver.complete events and cost emission', () => {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
},
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 7,
|
||||
web_search_requests: 1,
|
||||
},
|
||||
usage: { input_tokens: 10, output_tokens: 7, web_search_requests: 1 },
|
||||
finish_reason: 'stop',
|
||||
} as never);
|
||||
|
||||
@@ -768,10 +746,7 @@ describe('ChatCompletionDriver.complete validation event routing', () => {
|
||||
|
||||
describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
it('throws 402 `insufficient_funds` when the actor has no remaining credits', async () => {
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({ balance: 0, held: 0 });
|
||||
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 0, held: 0 });
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
@@ -806,10 +781,7 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
// Approx input cost is tiny (very short prompt), so allowed
|
||||
// output ≈ 100_000 / 2000 = 50 tokens, comfortably below the
|
||||
// caller's 10_000 ceiling.
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({ balance: 100_000, held: 0 });
|
||||
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 100_000, held: 0 });
|
||||
|
||||
const completeSpy = vi
|
||||
.spyOn(FakeChatProvider.prototype, 'complete')
|
||||
@@ -836,103 +808,50 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
expect(passed.max_tokens!).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(['multipliers', 'catalog overrides'])(
|
||||
'caps `max_tokens` at the conditional output rate using %s',
|
||||
async (pricingKind) => {
|
||||
vi.spyOn(
|
||||
FakeChatProvider.prototype,
|
||||
'models',
|
||||
).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'capme',
|
||||
aliases: [],
|
||||
costs_currency: 'usd-cents',
|
||||
costs: { input_tokens: 1000, output_tokens: 2000 },
|
||||
...(pricingKind === 'multipliers'
|
||||
? {
|
||||
long_context_pricing: {
|
||||
threshold: 10,
|
||||
input_multiplier: 2,
|
||||
output_multiplier: 1.5,
|
||||
},
|
||||
}
|
||||
: {
|
||||
pricingOverrides: [
|
||||
{
|
||||
minPromptTokens: 10,
|
||||
costs: {
|
||||
input_tokens: 2000,
|
||||
output_tokens: 3000,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
max_tokens: 8192,
|
||||
},
|
||||
]);
|
||||
const d = await makeDriver();
|
||||
|
||||
// A ~100-token prompt is past the threshold. 1_000_000 microcents at
|
||||
// 2000 * 1.5 per output token leaves at most 333 output tokens before
|
||||
// the prompt is paid for; the standard rate would allow ~450 after it.
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({ balance: 1_000_000, held: 0 });
|
||||
|
||||
const completeSpy = vi
|
||||
.spyOn(FakeChatProvider.prototype, 'complete')
|
||||
.mockResolvedValueOnce({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
},
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
finish_reason: 'stop',
|
||||
} as never);
|
||||
|
||||
await withTestActor(() =>
|
||||
d.complete({
|
||||
model: 'capme',
|
||||
messages: [{ role: 'user', content: 'x'.repeat(400) }],
|
||||
max_tokens: 10_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const passed = completeSpy.mock.calls[0]![0] as ICompleteArguments;
|
||||
expect(passed.max_tokens!).toBeLessThanOrEqual(333);
|
||||
expect(passed.max_tokens!).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('reserves a catalog fixed request fee before calling the provider', async () => {
|
||||
it('caps `max_tokens` at the long-context output rate for a prompt past the threshold', async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
id: 'request-fee',
|
||||
id: 'capme',
|
||||
aliases: [],
|
||||
costs_currency: 'usd-cents',
|
||||
max_tokens: 1000,
|
||||
costs: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
request: 1_000_001,
|
||||
costs: { input_tokens: 1000, output_tokens: 2000 },
|
||||
long_context_pricing: {
|
||||
threshold: 10,
|
||||
input_multiplier: 2,
|
||||
output_multiplier: 1.5,
|
||||
},
|
||||
max_tokens: 8192,
|
||||
},
|
||||
]);
|
||||
const d = await makeDriver();
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({ balance: 1_000_000, held: 0 });
|
||||
const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete');
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
d.complete({
|
||||
model: 'request-fee',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(completeSpy).not.toHaveBeenCalled();
|
||||
|
||||
// A ~100-token prompt is past the threshold. 1_000_000 microcents at
|
||||
// 2000 * 1.5 per output token leaves at most 333 output tokens before
|
||||
// the prompt is paid for; the standard rate would allow ~450 after it.
|
||||
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 1_000_000, held: 0 });
|
||||
|
||||
const completeSpy = vi
|
||||
.spyOn(FakeChatProvider.prototype, 'complete')
|
||||
.mockResolvedValueOnce({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
},
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
finish_reason: 'stop',
|
||||
} as never);
|
||||
|
||||
await withTestActor(() =>
|
||||
d.complete({
|
||||
model: 'capme',
|
||||
messages: [{ role: 'user', content: 'x'.repeat(400) }],
|
||||
max_tokens: 10_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const passed = completeSpy.mock.calls[0]![0] as ICompleteArguments;
|
||||
expect(passed.max_tokens!).toBeLessThanOrEqual(333);
|
||||
expect(passed.max_tokens!).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('throws 402 instead of leaving `max_tokens` unset when credits cannot afford one output token', async () => {
|
||||
@@ -952,10 +871,7 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
|
||||
// Pass the cheap pre-flight check but leave a balance too small to
|
||||
// afford a single 2000-microcent output token.
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({ balance: 100, held: 0 });
|
||||
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 100, held: 0 });
|
||||
|
||||
const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete');
|
||||
|
||||
@@ -993,31 +909,23 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
// extraCost alone (50_000) eats most of a 100_000-microcent balance;
|
||||
// the fast cost keys (half the standard rate) are what is left to
|
||||
// afford output with.
|
||||
(
|
||||
FakeChatProvider.prototype as unknown as {
|
||||
requestPricing: NonNullable<IChatProvider['requestPricing']>;
|
||||
}
|
||||
).requestPricing = () => ({
|
||||
(FakeChatProvider.prototype as unknown as {
|
||||
requestPricing: NonNullable<IChatProvider['requestPricing']>;
|
||||
}).requestPricing = () => ({
|
||||
inputKey: 'fast_input_tokens',
|
||||
outputKey: 'fast_output_tokens',
|
||||
extraCost: 50_000,
|
||||
});
|
||||
|
||||
try {
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({
|
||||
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({
|
||||
balance: 100_000,
|
||||
held: 0,
|
||||
});
|
||||
const completeSpy = vi
|
||||
.spyOn(FakeChatProvider.prototype, 'complete')
|
||||
.mockResolvedValueOnce({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
},
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
finish_reason: 'stop',
|
||||
} as never);
|
||||
@@ -1037,13 +945,12 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
expect(passed.max_tokens!).toBeLessThanOrEqual(50);
|
||||
expect(passed.max_tokens!).toBeGreaterThan(30);
|
||||
} finally {
|
||||
delete (
|
||||
FakeChatProvider.prototype as unknown as Record<string, unknown>
|
||||
).requestPricing;
|
||||
delete (FakeChatProvider.prototype as unknown as Record<string, unknown>)
|
||||
.requestPricing;
|
||||
}
|
||||
});
|
||||
|
||||
it("caps `max_tokens` and the hold at the provider's requestPricing maxTokens", async () => {
|
||||
it('rejects with 402 when a low balance cannot cover a tool\'s extraCost reservation', async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
id: 'capme',
|
||||
@@ -1055,87 +962,16 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
]);
|
||||
const d = await makeDriver();
|
||||
|
||||
(
|
||||
FakeChatProvider.prototype as unknown as {
|
||||
requestPricing: NonNullable<IChatProvider['requestPricing']>;
|
||||
}
|
||||
).requestPricing = () => ({ maxTokens: 100 });
|
||||
(FakeChatProvider.prototype as unknown as {
|
||||
requestPricing: NonNullable<IChatProvider['requestPricing']>;
|
||||
}).requestPricing = () => ({ extraCost: 1_000_000 });
|
||||
|
||||
try {
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({
|
||||
balance: 100_000_000,
|
||||
held: 0,
|
||||
});
|
||||
const reserve = vi.spyOn(
|
||||
server.services.metering,
|
||||
'reserveCredits',
|
||||
);
|
||||
const completeSpy = vi
|
||||
.spyOn(FakeChatProvider.prototype, 'complete')
|
||||
.mockResolvedValueOnce({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
},
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
finish_reason: 'stop',
|
||||
} as never);
|
||||
|
||||
await withTestActor(() =>
|
||||
d.complete({
|
||||
model: 'capme',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 10_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const passed = completeSpy.mock.calls[0]![0] as ICompleteArguments;
|
||||
expect(passed.max_tokens).toBe(100);
|
||||
// The balance and the request both allow more; the hold is the
|
||||
// tiny prompt plus 100 output tokens at 2000.
|
||||
const [, held] = reserve.mock.calls[0]!;
|
||||
expect(held).toBeGreaterThanOrEqual(100 * 2000);
|
||||
expect(held).toBeLessThan(101 * 2000);
|
||||
} finally {
|
||||
delete (
|
||||
FakeChatProvider.prototype as unknown as Record<string, unknown>
|
||||
).requestPricing;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects with 402 when a low balance cannot cover a tool's extraCost reservation", async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
id: 'capme',
|
||||
aliases: [],
|
||||
costs_currency: 'usd-cents',
|
||||
costs: { input_tokens: 1000, output_tokens: 2000 },
|
||||
max_tokens: 8192,
|
||||
},
|
||||
]);
|
||||
const d = await makeDriver();
|
||||
|
||||
(
|
||||
FakeChatProvider.prototype as unknown as {
|
||||
requestPricing: NonNullable<IChatProvider['requestPricing']>;
|
||||
}
|
||||
).requestPricing = () => ({ extraCost: 1_000_000 });
|
||||
|
||||
try {
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({
|
||||
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({
|
||||
balance: 100,
|
||||
held: 0,
|
||||
});
|
||||
const completeSpy = vi.spyOn(
|
||||
FakeChatProvider.prototype,
|
||||
'complete',
|
||||
);
|
||||
const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete');
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
@@ -1145,15 +981,11 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
tools: [{ type: 'web_search' }],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 402,
|
||||
legacyCode: 'insufficient_funds',
|
||||
});
|
||||
).rejects.toMatchObject({ statusCode: 402, legacyCode: 'insufficient_funds' });
|
||||
expect(completeSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
delete (
|
||||
FakeChatProvider.prototype as unknown as Record<string, unknown>
|
||||
).requestPricing;
|
||||
delete (FakeChatProvider.prototype as unknown as Record<string, unknown>)
|
||||
.requestPricing;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1161,20 +993,14 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
// unexempted `web_search_preview` became an unnamed function tool, which
|
||||
// OpenAI rejects and the provider's search pricing no longer recognizes.
|
||||
it('passes web_search_preview through to the provider unchanged', async () => {
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({
|
||||
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({
|
||||
balance: 100_000_000,
|
||||
held: 0,
|
||||
});
|
||||
const completeSpy = vi
|
||||
.spyOn(FakeChatProvider.prototype, 'complete')
|
||||
.mockResolvedValueOnce({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
},
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
finish_reason: 'stop',
|
||||
} as never);
|
||||
@@ -1259,10 +1085,7 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
},
|
||||
]);
|
||||
const d = await makeDriver();
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({ balance: 1_000_000, held: 0 });
|
||||
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 1_000_000, held: 0 });
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getActorSubscription',
|
||||
@@ -1293,10 +1116,7 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
},
|
||||
]);
|
||||
const d = await makeDriver();
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({ balance: 1_000_000, held: 0 });
|
||||
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 1_000_000, held: 0 });
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getActorSubscription',
|
||||
@@ -1335,10 +1155,7 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
]);
|
||||
const d = await makeDriver();
|
||||
// Plenty of credits so the credit gate doesn't intercept first.
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({ balance: 1_000_000, held: 0 });
|
||||
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 1_000_000, held: 0 });
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
@@ -1468,10 +1285,9 @@ describe('ChatCompletionDriver Claude usage.thinking_tokens', () => {
|
||||
const d = await makeClaudeDriver();
|
||||
// Affords exactly 300 output tokens at 1000 µ¢ each: above the real
|
||||
// 233 output tokens, below 233 + 110 thinking_tokens (343).
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({ balance: 300_000, held: 0 });
|
||||
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue(
|
||||
{ balance: 300_000, held: 0 },
|
||||
);
|
||||
vi.spyOn(ClaudeProvider.prototype, 'complete').mockResolvedValueOnce({
|
||||
message: { role: 'assistant', content: 'hi' },
|
||||
finish_reason: 'stop',
|
||||
@@ -1489,7 +1305,8 @@ describe('ChatCompletionDriver Claude usage.thinking_tokens', () => {
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
});
|
||||
return Context.get('driverMetadata') as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
});
|
||||
|
||||
expect(metadata?.usage_limited).toBeUndefined();
|
||||
@@ -1535,10 +1352,7 @@ describe('ChatCompletionDriver.complete normalization', () => {
|
||||
])(
|
||||
'normalizes a raw wire-form tool_choice (%j) before it reaches the provider',
|
||||
async (wireToolChoice, normalized) => {
|
||||
const completeSpy = vi.spyOn(
|
||||
FakeChatProvider.prototype,
|
||||
'complete',
|
||||
);
|
||||
const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete');
|
||||
completeSpy.mockResolvedValueOnce({
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
usage: {},
|
||||
@@ -1840,7 +1654,9 @@ describe('ChatCompletionDriver.complete OpenAI-shape normalization', () => {
|
||||
content_type: 'application/x-ndjson',
|
||||
});
|
||||
// Drain so the fake provider's populator finishes cleanly.
|
||||
await collectStream((res as unknown as { stream: Readable }).stream);
|
||||
await collectStream(
|
||||
(res as unknown as { stream: Readable }).stream,
|
||||
);
|
||||
});
|
||||
|
||||
it('a blocked prompt rerouted to fake-chat keeps its historical native shape', async () => {
|
||||
@@ -1899,11 +1715,8 @@ describe('ChatCompletionDriver.complete fallback and error envelope', () => {
|
||||
legacyCode: 'upstream_timeout',
|
||||
message: 'AI provider timed out',
|
||||
});
|
||||
const attempts = (
|
||||
caught as unknown as {
|
||||
fields: { attempts: { timedOut?: boolean }[] };
|
||||
}
|
||||
).fields.attempts;
|
||||
const attempts = (caught as unknown as { fields: { attempts: { timedOut?: boolean }[] } })
|
||||
.fields.attempts;
|
||||
expect(attempts).toHaveLength(1);
|
||||
expect(attempts[0].timedOut).toBe(true);
|
||||
});
|
||||
@@ -1998,9 +1811,8 @@ describe('ChatCompletionDriver.complete fallback and error envelope', () => {
|
||||
expect(caught).toBeInstanceOf(HttpError);
|
||||
expect(caught!.statusCode).toBe(500);
|
||||
expect(caught!.message).toBe('All providers failed');
|
||||
const attempts = (
|
||||
caught as unknown as { fields: { attempts: unknown[] } }
|
||||
).fields.attempts;
|
||||
const attempts = (caught as unknown as { fields: { attempts: unknown[] } })
|
||||
.fields.attempts;
|
||||
expect(Array.isArray(attempts)).toBe(true);
|
||||
// No second provider serves `fake`, so we record exactly one attempt
|
||||
// before falling out of the loop.
|
||||
@@ -2190,9 +2002,8 @@ describe('ChatCompletionDriver.countTokens', () => {
|
||||
|
||||
it('prefers the provider’s own countTokens hook when it exists', async () => {
|
||||
const hook = vi.fn().mockResolvedValue(42);
|
||||
(
|
||||
FakeChatProvider.prototype as unknown as { countTokens?: unknown }
|
||||
).countTokens = hook;
|
||||
(FakeChatProvider.prototype as unknown as { countTokens?: unknown }).countTokens =
|
||||
hook;
|
||||
try {
|
||||
const result = await driver[COUNT_TOKENS]({
|
||||
model: 'fake',
|
||||
@@ -2205,11 +2016,8 @@ describe('ChatCompletionDriver.countTokens', () => {
|
||||
provider: 'fake-chat',
|
||||
});
|
||||
} finally {
|
||||
delete (
|
||||
FakeChatProvider.prototype as unknown as {
|
||||
countTokens?: unknown;
|
||||
}
|
||||
).countTokens;
|
||||
delete (FakeChatProvider.prototype as unknown as { countTokens?: unknown })
|
||||
.countTokens;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Loaded 100 of 698 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user