mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cc00fa977 | ||
|
|
1a9e43f5e5 | ||
|
|
242a654166 | ||
|
|
41e4b582d3 | ||
|
|
2a49689fad | ||
|
|
b54b76715a | ||
|
|
d978538c9d | ||
|
|
e83e27f306 | ||
|
|
54f542ace4 | ||
|
|
18b96d529c | ||
|
|
f146da16d1 | ||
|
|
dd56d54753 | ||
|
|
e5fd30ccf0 | ||
|
|
517efda38a | ||
|
|
ea3801fb74 | ||
|
|
0786cc7db2 | ||
|
|
592020c899 | ||
|
|
9ff224e22f | ||
|
|
9ad7b99607 | ||
|
|
28f162ee92 | ||
|
|
fa62b3fb76 | ||
|
|
e950a87f29 | ||
|
|
1cb9dd756a | ||
|
|
be16bca1d9 | ||
|
|
2305d50e36 | ||
|
|
352427687e | ||
|
|
145d54cf69 | ||
|
|
471b900462 | ||
|
|
2b254b8892 | ||
|
|
2a15afc570 | ||
|
|
f9860b036b | ||
|
|
76e1f79b5e | ||
|
|
943a3427c6 | ||
|
|
38469a3615 | ||
|
|
a4d4a5595e | ||
|
|
de69269b10 | ||
|
|
8faff74793 | ||
|
|
e5ca303c50 | ||
|
|
c6c0dd5e29 | ||
|
|
e32bfa839d | ||
|
|
ff815f730d | ||
|
|
84aaf005ec | ||
|
|
f6a1e54642 | ||
|
|
d211a25c6d | ||
|
|
2e1bf86d2b | ||
|
|
4401b4c4dc | ||
|
|
62270ff9aa |
No files matched your search
+26
-8
@@ -17,6 +17,21 @@ 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:
|
||||
@@ -27,11 +42,12 @@ 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -52,7 +68,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 the Known Non-Issues list**: Reports matching an item in the "Known Non-Issues" section above are not eligible and will be closed.
|
||||
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.
|
||||
|
||||
Reports that do not meet these guidelines may not be eligible for a reward.
|
||||
|
||||
@@ -63,14 +79,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.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Please submit **one issue per report**.
|
||||
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.
|
||||
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.
|
||||
|
||||
## Reporting Process
|
||||
|
||||
@@ -83,6 +99,8 @@ 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
|
||||
@@ -95,7 +113,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.
|
||||
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.
|
||||
|
||||
## Payments Disclaimer
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@
|
||||
// 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 (clickhouse,
|
||||
// cacheUpdateHandler, pages, prodMeteringAndBilling, …) are intentionally
|
||||
// omitted.
|
||||
// Keys consumed only by closed-source / hosted-prod extensions are
|
||||
// intentionally omitted.
|
||||
|
||||
// ── Environment / identity ──────────────────────────────────────────
|
||||
"config_name": "template",
|
||||
@@ -35,7 +34,7 @@
|
||||
// Idle keep-alive timeout in ms. Must stay above the idle timeout of any
|
||||
// proxy in front, or the proxy reuses connections this server has closed
|
||||
// and its clients see 502s.
|
||||
"keep_alive_timeout": 620000,
|
||||
"keep_alive_timeout": 920000,
|
||||
"protocol": "http",
|
||||
"domain": "puter.localhost",
|
||||
// Fully-qualified externally-visible URL. Computed from protocol/domain/
|
||||
@@ -451,6 +450,10 @@
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://ark.ap-southeast.bytepluses.com/api/v3"
|
||||
},
|
||||
// Kling AI. One API key (not the legacy AccessKey/SecretKey pair)
|
||||
// powers video generation, image generation and TTS; `apiBaseUrl`
|
||||
// defaults to https://api-singapore.klingai.com.
|
||||
"kling": { "apiKey": "" },
|
||||
"zai": { "apiKey": "" },
|
||||
"alibaba": { "apiKey": "" },
|
||||
"hoonify": {
|
||||
|
||||
@@ -45,6 +45,10 @@ 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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -309,4 +309,41 @@ 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,9 +16,11 @@ import type { Request, Response } from 'express';
|
||||
const services = extension.import('service');
|
||||
const stores = extension.import('store');
|
||||
|
||||
// Cached on first request — the underlying cost catalogues are baked into
|
||||
// driver/controller source so they only change on deploy.
|
||||
// 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;
|
||||
let cachedAllCosts: Record<string, unknown>[] | null = null;
|
||||
let allCostsExpireAt = 0;
|
||||
|
||||
function collectAllCosts(): Record<string, unknown>[] {
|
||||
const all: Record<string, unknown>[] = [];
|
||||
@@ -187,12 +189,14 @@ export const handleMeteringGlobalUsage = async (
|
||||
res.json(globalUsage);
|
||||
};
|
||||
|
||||
// First hit walks the registries; subsequent hits serve the in-memory cache.
|
||||
// A hit walks the registries when the in-memory cache is missing or expired.
|
||||
export const handleMeteringAllCosts = async (
|
||||
_req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
if (!cachedAllCosts) {
|
||||
if (!cachedAllCosts || Date.now() >= allCostsExpireAt) {
|
||||
allCostsExpireAt =
|
||||
(Math.floor(Date.now() / ALL_COSTS_TTL_MS) + 1) * ALL_COSTS_TTL_MS;
|
||||
const raw = collectAllCosts();
|
||||
const multiplier = creditMultiplierFrom(extension.config);
|
||||
// Price the catalogue in the same unit the usage endpoints report:
|
||||
|
||||
@@ -170,6 +170,81 @@ 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();
|
||||
|
||||
+18
-5
@@ -36,6 +36,18 @@ 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' });
|
||||
@@ -62,10 +74,10 @@ export const handleWhoami = async (
|
||||
|
||||
// Strip sensitive fields for app actors
|
||||
if (!isUser) {
|
||||
const canReadEmail = await services.permission
|
||||
.check(actor, `user:${user.uuid}:email:read`)
|
||||
.catch(() => false);
|
||||
if (!canReadEmail) {
|
||||
const emailReadable = await (user.uuid === actor.user.uuid
|
||||
? emailCheck
|
||||
: canReadEmail(user.uuid));
|
||||
if (!emailReadable) {
|
||||
delete details.email;
|
||||
delete details.unconfirmed_email;
|
||||
}
|
||||
@@ -109,7 +121,8 @@ 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
+27
-27
@@ -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.14",
|
||||
"libphonenumber-js": "1.13.15",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
@@ -5921,9 +5921,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/busboy": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz",
|
||||
"integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==",
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.5.tgz",
|
||||
"integrity": "sha512-IwqrfVFxWa23c4WeE7uZswssCqGfvnl5Q7b1pdshbT3HaQDa3UIbJx5BAxtcj9nZYX9tVDArM9TUv9SpvORbpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11523,9 +11523,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.13.14",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.14.tgz",
|
||||
"integrity": "sha512-llihgCcx0BFLksecLP+x1J+6JDE1GsXS1RN/LoPF6qcwpeQcnjj0lcvZxY8AzbEpYwyZWPZW/nDuqkqzm3amiw==",
|
||||
"version": "1.13.15",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.15.tgz",
|
||||
"integrity": "sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
@@ -14367,9 +14367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.9",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
|
||||
"integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz",
|
||||
"integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -15578,13 +15578,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/smtp-server": {
|
||||
"version": "3.19.17",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.17.tgz",
|
||||
"integrity": "sha512-/9RwyNTcR8osMQWOvC8NV8l57ddPyYXvuG+wa1Ua4cD/I41z7zCMsGtXLa9vxlrggjRrfejr50X0WS/9NuVWqQ==",
|
||||
"version": "3.19.21",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.21.tgz",
|
||||
"integrity": "sha512-d0wWijB5dK4gImQidwWMlcGsycqcDCJV0VdQbfz61rgkDqnzAx0Epvi0o5JN2nnr/YwriVC+XV0T8YgcoB0bng==",
|
||||
"license": "MIT-0",
|
||||
"dependencies": {
|
||||
"ipv6-normalize": "1.0.1",
|
||||
"nodemailer": "10.0.14",
|
||||
"nodemailer": "10.1.0",
|
||||
"punycode.js": "2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -15592,9 +15592,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/smtp-server/node_modules/nodemailer": {
|
||||
"version": "10.0.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.14.tgz",
|
||||
"integrity": "sha512-eJoLFKg55fesSlzUekmfOv/SBXh4KUYT8oiQc2RLP0tfG0rbNMJBK7wgHJwXTJwqyKlSJMz9UzDZ4v+aHEvo8g==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.0.tgz",
|
||||
"integrity": "sha512-fXW8AulB25aOTuwfRYV6lqHyfz2VM4rr4SGXH03mE7x5znEUpzL5mu6anARUpZN7744Ziwkmr4AxSLPJsBQspg==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -16730,9 +16730,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
|
||||
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.3.tgz",
|
||||
"integrity": "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
@@ -16775,9 +16775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"version": "8.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.6.tgz",
|
||||
"integrity": "sha512-aRAHIxx5SW5zc1sMqFwJU/t5qaFfXb5Hj0J7W0VXihu/X99+346tz6080tO1T1xVIJORX/b3zVTXGFqUXh35uw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -17712,19 +17712,19 @@
|
||||
"postal-mime": "^3.0.1",
|
||||
"replicate": "^1.0.0",
|
||||
"sharp": "^0.35.5",
|
||||
"smtp-server": "^3.19.17",
|
||||
"smtp-server": "^3.19.21",
|
||||
"socket.io": "^4.8.4",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"together-ai": "^0.33.0",
|
||||
"ua-parser-js": "^1.0.41",
|
||||
"undici": "^7.29.1",
|
||||
"uuid": "^14.0.2",
|
||||
"uuid": "^14.0.3",
|
||||
"validator": "^13.15.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/busboy": "^1.5.5",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
@@ -17735,7 +17735,7 @@
|
||||
"@types/validator": "^13.15.10",
|
||||
"pgmock": "^1.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.1.5",
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.0.14"
|
||||
}
|
||||
},
|
||||
|
||||
+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.14",
|
||||
"libphonenumber-js": "1.13.15",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,12 @@ 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 {
|
||||
@@ -330,6 +336,30 @@ 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,6 +435,105 @@ 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];
|
||||
@@ -472,6 +571,23 @@ 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,8 +78,13 @@ 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);
|
||||
this.dbPrimaryRead = this.createPrimaryReadBatcher(
|
||||
this.primaryPool,
|
||||
primaryReadFlushes,
|
||||
);
|
||||
|
||||
if (dbConf.replica) {
|
||||
this.replicaPool = this.createPool(dbConf.replica);
|
||||
@@ -90,7 +95,12 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
this.configuration = Configuration.SINGLE;
|
||||
}
|
||||
|
||||
this.dbReplica = this.createReplicaBatcher(this.replicaPool);
|
||||
this.dbReplica = this.createReplicaBatcher(
|
||||
this.replicaPool,
|
||||
this.configuration === Configuration.SINGLE
|
||||
? primaryReadFlushes
|
||||
: { count: 0 },
|
||||
);
|
||||
|
||||
await this.applyMigrationPaths(
|
||||
() => this.primaryPool.promise().getConnection(),
|
||||
@@ -164,12 +174,14 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,9 +286,14 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
}
|
||||
|
||||
/** Reads that must see the primary, still coalesced. */
|
||||
private createPrimaryReadBatcher(pool: Pool): SQLBatcher {
|
||||
private createPrimaryReadBatcher(
|
||||
pool: Pool,
|
||||
inFlight: { count: number },
|
||||
): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 30,
|
||||
maxInFlightImmediate: 8,
|
||||
inFlight,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'primary',
|
||||
readOnly: true,
|
||||
@@ -284,9 +301,14 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
private createReplicaBatcher(pool: Pool): SQLBatcher {
|
||||
private createReplicaBatcher(
|
||||
pool: Pool,
|
||||
inFlight: { count: number },
|
||||
): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 10,
|
||||
maxInFlightImmediate: 8,
|
||||
inFlight,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'replica',
|
||||
readOnly: true,
|
||||
|
||||
@@ -76,6 +76,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
export class SQLBatcher {
|
||||
dbPool;
|
||||
maxTimeInQueue;
|
||||
maxInFlightImmediate;
|
||||
maxBatchSize;
|
||||
maxQueueSize;
|
||||
failureThreshold;
|
||||
@@ -84,7 +85,9 @@ export class SQLBatcher {
|
||||
readOnly;
|
||||
acquireTimeoutMs;
|
||||
queue = [];
|
||||
timeouts = [];
|
||||
#flushTimer = null;
|
||||
#flushTimerIsImmediate = false;
|
||||
#inFlight;
|
||||
#consecutiveFailures = 0;
|
||||
#lastFailureAt = 0;
|
||||
#metricAttrs;
|
||||
@@ -93,6 +96,11 @@ 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
|
||||
@@ -111,6 +119,8 @@ export class SQLBatcher {
|
||||
dbPool,
|
||||
{
|
||||
maxTimeInQueue = 20,
|
||||
maxInFlightImmediate = 0,
|
||||
inFlight = { count: 0 },
|
||||
maxBatchSize = 50,
|
||||
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
||||
failureThreshold = DEFAULT_FAILURE_THRESHOLD,
|
||||
@@ -122,6 +132,8 @@ export class SQLBatcher {
|
||||
) {
|
||||
this.dbPool = dbPool;
|
||||
this.maxTimeInQueue = maxTimeInQueue;
|
||||
this.maxInFlightImmediate = maxInFlightImmediate;
|
||||
this.#inFlight = inFlight;
|
||||
this.maxBatchSize = maxBatchSize;
|
||||
this.maxQueueSize = maxQueueSize;
|
||||
this.failureThreshold = failureThreshold;
|
||||
@@ -185,17 +197,34 @@ export class SQLBatcher {
|
||||
|
||||
if (this.queue.length >= this.maxBatchSize) {
|
||||
this.flush(this.queue.splice(0, this.maxBatchSize));
|
||||
} else if (this.queue.length === 1) {
|
||||
this.timeouts.push(
|
||||
setTimeout(() => {
|
||||
this.flush(this.queue.splice(0, this.queue.length));
|
||||
}, this.maxTimeInQueue),
|
||||
);
|
||||
} else if (this.#flushTimer === null) {
|
||||
this.#armFlushTimer();
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -252,12 +281,18 @@ export class SQLBatcher {
|
||||
}
|
||||
|
||||
async flush(batch) {
|
||||
const timeout = this.timeouts.shift();
|
||||
if (timeout && !timeout._destroyed) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
this.#clearFlushTimer();
|
||||
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,6 +69,356 @@ 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 = 89;
|
||||
const CURRENT_SCHEMA_VERSION = 90;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -930,6 +930,7 @@ 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,6 +128,7 @@ 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']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
@@ -206,6 +207,9 @@ 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.
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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,3 +1,9 @@
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import {
|
||||
BasicTracerProvider,
|
||||
InMemorySpanExporter,
|
||||
SimpleSpanProcessor,
|
||||
} from '@opentelemetry/sdk-trace-base';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
@@ -207,6 +213,122 @@ 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,6 +94,7 @@ 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 () => {
|
||||
@@ -118,6 +119,43 @@ 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.
|
||||
*
|
||||
|
||||
@@ -459,7 +459,11 @@ export type EventMap = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
'storage.quota.bonus': { userId: number; extra: number };
|
||||
/**
|
||||
* 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 };
|
||||
|
||||
// ---- Key-value ----
|
||||
/**
|
||||
@@ -919,10 +923,11 @@ 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>;
|
||||
|
||||
@@ -338,6 +338,42 @@ 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 } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
|
||||
@@ -680,3 +680,89 @@ 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=',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2259,6 +2259,62 @@ describe('AuthController grant flows', () => {
|
||||
).not.toContain(permission);
|
||||
});
|
||||
|
||||
it('revoke-user-app `*`: deletes the worker sessions that app deployed for the user, and only those', async () => {
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `tr-worker-${uuidv4()}`,
|
||||
title: 'TestRevokeWorkerApp',
|
||||
index_url: `https://tr-worker-${uuidv4()}.example.test/`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const workerName = () => `wk-${uuidv4().slice(0, 8)}`;
|
||||
const appWorker = await server.stores.session.getOrCreateWorker(
|
||||
issuer.id,
|
||||
{ appUid: app.uid, workerName: workerName() },
|
||||
);
|
||||
const otherApp = await server.stores.session.getOrCreateWorker(
|
||||
issuer.id,
|
||||
{ appUid: `app-${uuidv4()}`, workerName: workerName() },
|
||||
);
|
||||
const userScoped = await server.stores.session.getOrCreateWorker(
|
||||
issuer.id,
|
||||
{ appUid: null, workerName: workerName() },
|
||||
);
|
||||
const exists = async (uuid: string) =>
|
||||
(
|
||||
await server.clients.db.read(
|
||||
'SELECT 1 FROM `sessions` WHERE `uuid` = ?',
|
||||
[uuid],
|
||||
)
|
||||
).length > 0;
|
||||
|
||||
// A single permission leaves the app's workers running as before.
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleRevokeUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: 'service:x:ii:read' },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
);
|
||||
expect(await exists(appWorker.uuid)).toBe(true);
|
||||
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleRevokeUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: '*' },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
);
|
||||
expect(await exists(appWorker.uuid)).toBe(false);
|
||||
expect(await exists(otherApp.uuid)).toBe(true);
|
||||
expect(await exists(userScoped.uuid)).toBe(true);
|
||||
});
|
||||
|
||||
it('grant-user-app: 400 on non-string or oversized origin/app_uid/permission', async () => {
|
||||
const cases = [
|
||||
{ origin: { host: 'evil' }, permission: 'service:x:ii:read' },
|
||||
@@ -3541,6 +3597,46 @@ 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, () =>
|
||||
@@ -8252,7 +8348,7 @@ describe('AuthController 2FA flows', () => {
|
||||
otp_recovery_codes: 'a,b,c',
|
||||
});
|
||||
const res = makeRes();
|
||||
await controller.handleDisable2fa(makeReq({}, { actor }), res);
|
||||
await controller.handleDisable2fa(makeReq({ code: liveTotp(user.username, 'TESTSECRETBASE32') }, { actor }), res);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
const after = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
@@ -9347,56 +9443,6 @@ 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();
|
||||
|
||||
@@ -2133,10 +2133,16 @@ export class AuthController extends PuterController {
|
||||
const password_hash = await bcrypt.hash(password, 8);
|
||||
let result;
|
||||
try {
|
||||
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],
|
||||
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 },
|
||||
);
|
||||
} catch (e) {
|
||||
if (!isOwnedEmailConflict(e)) throw e;
|
||||
@@ -2152,17 +2158,11 @@ export class AuthController extends PuterController {
|
||||
{ legacyCode: 'email_already_in_use' as never },
|
||||
);
|
||||
}
|
||||
const affected =
|
||||
(result as { affectedRows?: number; changes?: number })
|
||||
?.affectedRows ??
|
||||
(result as { affectedRows?: number; changes?: number })?.changes ??
|
||||
0;
|
||||
if (affected === 0) {
|
||||
if (!result) {
|
||||
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
|
||||
@@ -2351,6 +2351,34 @@ 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: {
|
||||
@@ -2371,6 +2399,17 @@ 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', {
|
||||
@@ -2412,6 +2451,7 @@ export class AuthController extends PuterController {
|
||||
token: confirm_token,
|
||||
user_id: req.actor!.user.id,
|
||||
purpose: 'change-email',
|
||||
twoFactorVerified: Boolean(user.otp_enabled),
|
||||
},
|
||||
{ expiresIn: '1h' },
|
||||
);
|
||||
@@ -2429,10 +2469,7 @@ export class AuthController extends PuterController {
|
||||
console.warn('[change-email] new-address email failed:', e);
|
||||
}
|
||||
// Notify the old address too
|
||||
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
||||
force: true,
|
||||
});
|
||||
if (user?.email) {
|
||||
if (user.email) {
|
||||
try {
|
||||
await (
|
||||
this.clients.email as unknown as {
|
||||
@@ -2489,19 +2526,21 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
) {
|
||||
throw new HttpError(400, 'Invalid or expired token.', {
|
||||
legacyCode: 'token_expired' as never,
|
||||
});
|
||||
@@ -3265,6 +3304,12 @@ export class AuthController extends PuterController {
|
||||
app_uid,
|
||||
meta ?? undefined,
|
||||
);
|
||||
// Withdrawing everything also retires the workers the app deployed
|
||||
// under this user; their tokens would otherwise act as the app.
|
||||
await this.services.auth.deleteWorkerSessionsForApp(
|
||||
req.actor!.user!.id as number,
|
||||
app_uid,
|
||||
);
|
||||
} else {
|
||||
for (const entry of list) {
|
||||
await this.services.permission.revokeUserAppPermission(
|
||||
@@ -3744,6 +3789,7 @@ export class AuthController extends PuterController {
|
||||
APP_AUTHENTICATED_FLAG,
|
||||
{},
|
||||
{},
|
||||
app,
|
||||
);
|
||||
|
||||
const tokenPromise: Promise<{ token: string; expiresAt?: number }> =
|
||||
@@ -3753,6 +3799,7 @@ export class AuthController extends PuterController {
|
||||
token: await this.services.auth.getUserAppToken(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
app,
|
||||
),
|
||||
}))();
|
||||
|
||||
@@ -3855,6 +3902,7 @@ export class AuthController extends PuterController {
|
||||
result.token = await this.services.auth.getUserAppToken(
|
||||
req.actor!,
|
||||
app!.uid,
|
||||
app!,
|
||||
);
|
||||
}
|
||||
res.json(result);
|
||||
@@ -4157,6 +4205,8 @@ 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,
|
||||
@@ -4234,13 +4284,10 @@ export class AuthController extends PuterController {
|
||||
throw new HttpError(400, 'No session bound to this actor', {
|
||||
legacyCode: 'session_required' as never,
|
||||
});
|
||||
const user = await this.stores.user.getById(req.actor.user.id!);
|
||||
if (!user)
|
||||
throw new HttpError(404, 'User not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
// Token auth built this actor from the user's row in this request, and
|
||||
// a deleted user fails it.
|
||||
const guiToken = this.services.auth.createGuiToken(
|
||||
user,
|
||||
req.actor.user as UserRow,
|
||||
req.actor.session.uid,
|
||||
);
|
||||
res.json({ token: guiToken });
|
||||
@@ -4270,13 +4317,9 @@ export class AuthController extends PuterController {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
const user = await this.stores.user.getById(req.actor.user.id!);
|
||||
if (!user) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
// As in `/get-gui-token`: the actor is the user's row as auth read it.
|
||||
const sessionToken = this.services.auth.createSessionTokenForSession(
|
||||
user,
|
||||
req.actor.user as UserRow,
|
||||
req.actor.session.uid,
|
||||
);
|
||||
res.cookie(this.config.cookie_name ?? 'puter_token', sessionToken, {
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
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 type { IConfig } from '../../types.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({ teams_enabled: true } as IConfig);
|
||||
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 owner = await account();
|
||||
const member = await account(true, true);
|
||||
const team = await env.server.services.team.createTeam(owner.id, {
|
||||
name: 'Required 2FA',
|
||||
});
|
||||
await env.server.stores.team.addMember(team.uid, member.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
await env.server.services.team.updateTeam(team.uid, owner.id, {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -823,9 +823,10 @@ describe('DriverController.#handleCall tracing', () => {
|
||||
'driver.method': 'set',
|
||||
'driver.name': 'puter-kvstore',
|
||||
});
|
||||
// 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');
|
||||
// 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');
|
||||
expect(names.some((n) => n.startsWith('ddb.'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'node:stream';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
/**
|
||||
@@ -549,3 +551,111 @@ 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');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -20,12 +20,15 @@
|
||||
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 { FS_ROUTE_REFUSAL } from '../../services/acl/ACLService.js';
|
||||
import { pipeToResponse } from '../../core/http/pipeToResponse.js';
|
||||
import {
|
||||
FS_ROUTE_REFUSAL,
|
||||
type ResourceDescriptor,
|
||||
} from '../../services/acl/ACLService.js';
|
||||
import { clientParentUid } from '../../services/fs/rootListing.js';
|
||||
import {
|
||||
expandTildePath,
|
||||
@@ -42,7 +45,11 @@ import type {
|
||||
UploadedBatchWriteItem,
|
||||
UploadProgressTrackerLike,
|
||||
} from '../../services/fs/types.js';
|
||||
import type { FSEntry, FSEntryWriteInput } from '../../stores/fs/FSEntry.js';
|
||||
import type {
|
||||
FSEntry,
|
||||
FSEntryWriteInput,
|
||||
PendingUploadSession,
|
||||
} from '../../stores/fs/FSEntry.js';
|
||||
import {
|
||||
runWithConcurrencyLimit,
|
||||
runWithConcurrencyLimitSettled,
|
||||
@@ -75,6 +82,7 @@ import {
|
||||
type GuiItemEventName,
|
||||
loadLegacyAssociatedApps,
|
||||
replacedByMove,
|
||||
resolveOrNotFound,
|
||||
signEntryThumbnail,
|
||||
} from './legacyFsHelpers.js';
|
||||
import type {
|
||||
@@ -150,8 +158,14 @@ 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,
|
||||
@@ -218,6 +232,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/startBatchWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -324,6 +339,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/completeWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -366,6 +382,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/completeBatchWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -396,26 +413,26 @@ export class FSController extends PuterController {
|
||||
userId,
|
||||
requests,
|
||||
);
|
||||
const updatedResponse = await runWithConcurrencyLimit(
|
||||
response,
|
||||
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,
|
||||
};
|
||||
}),
|
||||
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)),
|
||||
);
|
||||
@@ -423,6 +440,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/abortWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
})
|
||||
@@ -437,12 +455,31 @@ 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,
|
||||
@@ -463,18 +500,22 @@ export class FSController extends PuterController {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
await this.#assertUploadSessionWriteAccess(req, userId, [
|
||||
req.body?.uploadId,
|
||||
]);
|
||||
const sessions = 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,
|
||||
@@ -530,6 +571,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/batchWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_WRITE_LIMIT,
|
||||
@@ -865,17 +907,13 @@ export class FSController extends PuterController {
|
||||
preparedBatch,
|
||||
uploadedItems,
|
||||
);
|
||||
const updatedResponses = await runWithConcurrencyLimit(
|
||||
writeResponses,
|
||||
const updatedResponses = await this.#applyWriteResponsesSideEffects(
|
||||
userId,
|
||||
writeResponses.map((writeResponse, index) => ({
|
||||
response: writeResponse,
|
||||
guiMetadata: preparedBatch?.items[index]?.guiMetadata,
|
||||
})),
|
||||
32,
|
||||
async (writeResponse, index) => {
|
||||
const preparedItem = preparedBatch?.items[index];
|
||||
return this.#applyWriteResponseSideEffects(
|
||||
userId,
|
||||
writeResponse,
|
||||
preparedItem?.guiMetadata,
|
||||
);
|
||||
},
|
||||
);
|
||||
res.json(
|
||||
updatedResponses.map((r) => this.#withRequiredClientFsEntry(r)),
|
||||
@@ -988,17 +1026,13 @@ export class FSController extends PuterController {
|
||||
preparedBatch,
|
||||
uploadedItems,
|
||||
);
|
||||
const updatedResponses = await runWithConcurrencyLimit(
|
||||
writeResponses,
|
||||
const updatedResponses = await this.#applyWriteResponsesSideEffects(
|
||||
userId,
|
||||
writeResponses.map((writeResponse, index) => ({
|
||||
response: writeResponse,
|
||||
guiMetadata: filteredRequests[index]?.guiMetadata,
|
||||
})),
|
||||
32,
|
||||
async (writeResponse, index) => {
|
||||
const requestBody = filteredRequests[index];
|
||||
return this.#applyWriteResponseSideEffects(
|
||||
userId,
|
||||
writeResponse,
|
||||
requestBody?.guiMetadata,
|
||||
);
|
||||
},
|
||||
);
|
||||
res.json(
|
||||
updatedResponses.map((r) => this.#withRequiredClientFsEntry(r)),
|
||||
@@ -1021,7 +1055,9 @@ export class FSController extends PuterController {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = this.#toObjectRecord(req.body);
|
||||
const entry = await this.#resolveEntryForRequest(body);
|
||||
await this.#assertAccess(actor, entry.path, 'see');
|
||||
// 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');
|
||||
|
||||
const wantsSize = this.#toBoolean(body.return_size);
|
||||
const wantsShares = this.#toBoolean(body.return_shares);
|
||||
@@ -1039,7 +1075,7 @@ export class FSController extends PuterController {
|
||||
const mayReadSize =
|
||||
entry.isDir &&
|
||||
wantsSize &&
|
||||
(await this.#canAccess(actor, entry.path, 'list'));
|
||||
(await this.#canAccess(actor, descriptor, 'list'));
|
||||
|
||||
const [subtreeSize, suggestedApps, shareFlags, shares, parentUid] =
|
||||
await Promise.all([
|
||||
@@ -1488,19 +1524,14 @@ export class FSController extends PuterController {
|
||||
);
|
||||
res.status(range ? 206 : 200);
|
||||
|
||||
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;
|
||||
}
|
||||
await pipeToResponse(download.body, res);
|
||||
}
|
||||
|
||||
// -- Mutation routes ------------------------------------------------
|
||||
|
||||
@Post('/mkdir', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1546,6 +1577,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/touch', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1586,6 +1618,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/rename', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1609,6 +1642,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/delete', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1635,6 +1669,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/move', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1688,6 +1723,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/copy', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
@@ -1739,6 +1775,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/mkshortcut', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1790,7 +1827,6 @@ 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;
|
||||
@@ -1810,15 +1846,8 @@ export class FSController extends PuterController {
|
||||
? source.id
|
||||
: undefined,
|
||||
};
|
||||
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;
|
||||
// The resolved path is the owner's real one, so a miss must not echo it.
|
||||
return resolveOrNotFound(this.stores.fsEntry, ref);
|
||||
}
|
||||
|
||||
async #assertCanDedupeCreate(actor: Actor, targetPath: string) {
|
||||
@@ -1835,16 +1864,20 @@ export class FSController extends PuterController {
|
||||
/** As `#assertAccess`, but for what to include rather than what to serve. */
|
||||
async #canAccess(
|
||||
actor: Actor,
|
||||
path: string,
|
||||
path: string | ResourceDescriptor,
|
||||
mode: 'see' | 'list' | 'read' | 'write',
|
||||
): Promise<boolean> {
|
||||
const acl = this.services.acl;
|
||||
return acl.check(actor, acl.fsDescriptor(path), mode);
|
||||
return acl.check(
|
||||
actor,
|
||||
typeof path === 'string' ? acl.fsDescriptor(path) : path,
|
||||
mode,
|
||||
);
|
||||
}
|
||||
|
||||
async #assertAccess(
|
||||
actor: Actor,
|
||||
path: string,
|
||||
path: string | ResourceDescriptor,
|
||||
mode: 'see' | 'list' | 'read' | 'write',
|
||||
) {
|
||||
await this.services.acl.assertFsAccess(
|
||||
@@ -2240,7 +2273,8 @@ 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),
|
||||
@@ -2478,19 +2512,24 @@ 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<void> {
|
||||
): Promise<PendingUploadSession[] | undefined> {
|
||||
// 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;
|
||||
return undefined;
|
||||
}
|
||||
const sessions = await this.services.fs.getUploadSessions(
|
||||
userId,
|
||||
@@ -2505,6 +2544,9 @@ export class FSController extends PuterController {
|
||||
})),
|
||||
{ pathAlreadyNormalized: true },
|
||||
);
|
||||
return knownUploadIds.length === uploadIds.length
|
||||
? sessions
|
||||
: undefined;
|
||||
}
|
||||
|
||||
#toEventGuiMetadata(
|
||||
@@ -2619,16 +2661,16 @@ export class FSController extends PuterController {
|
||||
return this.#estimateDataUrlSize(thumbnail) > MAX_THUMBNAIL_BYTES;
|
||||
}
|
||||
|
||||
async #applyThumbnailAfterWrite(
|
||||
userId: number,
|
||||
/** The thumbnail a write should store on `fsEntry`, or null for none. */
|
||||
async #thumbnailToStore(
|
||||
fsEntry: FSEntry,
|
||||
requestedThumbnail: string | null | undefined,
|
||||
): Promise<FSEntry> {
|
||||
): Promise<string | null> {
|
||||
if (!requestedThumbnail || this.#isAppDataPath(fsEntry.path)) {
|
||||
return fsEntry;
|
||||
return null;
|
||||
}
|
||||
if (this.#isOversizedThumbnailDataUrl(requestedThumbnail)) {
|
||||
return fsEntry;
|
||||
return null;
|
||||
}
|
||||
|
||||
const thumbnailPayload = {
|
||||
@@ -2649,15 +2691,10 @@ export class FSController extends PuterController {
|
||||
? thumbnailPayload.url
|
||||
: null;
|
||||
|
||||
if (finalThumbnail === fsEntry.thumbnail || finalThumbnail === null) {
|
||||
return fsEntry;
|
||||
if (finalThumbnail === fsEntry.thumbnail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.services.fs.updateEntryThumbnail(
|
||||
userId,
|
||||
fsEntry.uuid,
|
||||
finalThumbnail,
|
||||
);
|
||||
return finalThumbnail;
|
||||
}
|
||||
|
||||
#toThumbnailPrepareItem(
|
||||
@@ -2887,36 +2924,130 @@ export class FSController extends PuterController {
|
||||
response: WriteResponse,
|
||||
guiMetadata: WriteGuiMetadata | undefined,
|
||||
): Promise<WriteResponse> {
|
||||
let fsEntry = response.fsEntry;
|
||||
const [result] = await this.#applyWriteResponsesSideEffects(
|
||||
userId,
|
||||
[{ response, guiMetadata }],
|
||||
1,
|
||||
);
|
||||
return result ?? response;
|
||||
}
|
||||
|
||||
const hashEventPromise = this.#runNonCritical(async () => {
|
||||
await this.#emitWriteHashEvent(
|
||||
response.contentHashSha256,
|
||||
fsEntry.uuid,
|
||||
);
|
||||
}, 'emitWriteHashEvent');
|
||||
/**
|
||||
* 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 };
|
||||
};
|
||||
|
||||
await this.#runNonCritical(async () => {
|
||||
fsEntry = await this.#applyThumbnailAfterWrite(
|
||||
userId,
|
||||
response.fsEntry,
|
||||
response.requestedThumbnail,
|
||||
);
|
||||
}, 'applyThumbnailAfterWrite');
|
||||
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 () => {
|
||||
await this.#emitGuiWriteEvent(
|
||||
response.wasOverwrite
|
||||
? 'outer.gui.item.updated'
|
||||
: 'outer.gui.item.added',
|
||||
fsEntry,
|
||||
guiMetadata,
|
||||
);
|
||||
}, 'emitGuiWriteEvent');
|
||||
// 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 hashEventPromise;
|
||||
|
||||
return { ...response, fsEntry };
|
||||
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;
|
||||
}
|
||||
|
||||
#shouldIgnoreUploadPath(targetPath: string): boolean {
|
||||
|
||||
@@ -26,6 +26,7 @@ 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';
|
||||
@@ -1256,8 +1257,271 @@ 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();
|
||||
@@ -1381,6 +1645,48 @@ 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],
|
||||
@@ -1447,6 +1753,66 @@ 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();
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
* 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,6 +467,88 @@ 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();
|
||||
|
||||
@@ -30,6 +30,7 @@ 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,
|
||||
@@ -51,6 +52,7 @@ 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,
|
||||
@@ -209,12 +211,19 @@ 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.
|
||||
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.
|
||||
//
|
||||
// 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 reads = { ...apiOptions, allowAccessToken: true } as RouteOptions;
|
||||
router.post('/stat', { ...reads, rateLimit: FS_STAT_LIMIT }, this.stat);
|
||||
router.post(
|
||||
@@ -264,6 +273,8 @@ export class LegacyFSController extends PuterController {
|
||||
'/batch',
|
||||
{
|
||||
...spends,
|
||||
// Every op authorizes its own target.
|
||||
allowAccessToken: true,
|
||||
rateLimit: FS_BATCH_LIMIT,
|
||||
concurrent: FS_BATCH_CONCURRENT,
|
||||
},
|
||||
@@ -494,6 +505,16 @@ 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),
|
||||
@@ -521,12 +542,7 @@ export class LegacyFSController extends PuterController {
|
||||
});
|
||||
|
||||
// Optional hydrations:
|
||||
if (entry.isDir && getBoolean(body, 'return_size')) {
|
||||
shaped.size = await this.services.fs.getSubtreeSize(
|
||||
userId,
|
||||
entry.path,
|
||||
);
|
||||
}
|
||||
if (subtreeSize) shaped.size = await subtreeSize;
|
||||
// `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 = [];
|
||||
@@ -914,7 +930,9 @@ 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;
|
||||
@@ -1305,10 +1323,7 @@ export class LegacyFSController extends PuterController {
|
||||
);
|
||||
res.status(range ? 206 : 200);
|
||||
|
||||
download.body.on('error', (err) => {
|
||||
res.destroy(err);
|
||||
});
|
||||
download.body.pipe(res);
|
||||
void pipeToResponse(download.body, res);
|
||||
};
|
||||
|
||||
tokenRead = async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -1391,7 +1406,8 @@ 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: [],
|
||||
};
|
||||
@@ -1439,11 +1455,13 @@ 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,
|
||||
entry.path,
|
||||
descriptor,
|
||||
'read',
|
||||
);
|
||||
let finalAction: 'read' | 'write' = 'read';
|
||||
@@ -1455,7 +1473,7 @@ export class LegacyFSController extends PuterController {
|
||||
// publishes the descriptor, so there is nothing to hide.
|
||||
const writeOk = await this.services.acl.check(
|
||||
actor,
|
||||
this.services.acl.fsDescriptor(entry.path),
|
||||
descriptor,
|
||||
'write',
|
||||
);
|
||||
finalAction = writeOk ? 'write' : 'read';
|
||||
@@ -1819,10 +1837,7 @@ export class LegacyFSController extends PuterController {
|
||||
);
|
||||
res.status(range ? 206 : 200);
|
||||
|
||||
download.body.on('error', (err) => {
|
||||
res.destroy(err);
|
||||
});
|
||||
download.body.pipe(res);
|
||||
void pipeToResponse(download.body, res);
|
||||
};
|
||||
|
||||
/** GET|POST /df — user storage allowance. */
|
||||
@@ -1986,7 +2001,10 @@ 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',
|
||||
@@ -2092,10 +2110,7 @@ export class LegacyFSController extends PuterController {
|
||||
);
|
||||
res.status(range ? 206 : 200);
|
||||
|
||||
download.body.on('error', (err) => {
|
||||
res.destroy(err);
|
||||
});
|
||||
download.body.pipe(res);
|
||||
void pipeToResponse(download.body, res);
|
||||
};
|
||||
|
||||
async #emitGuiEvent(
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { createTestUser, setupTestServer } from '../../testUtil.js';
|
||||
import {
|
||||
asRecord,
|
||||
fsEntryMimeType,
|
||||
@@ -360,7 +362,7 @@ describe('toLegacyEntry', () => {
|
||||
|
||||
it('probes for children to set is_empty on a directory', async () => {
|
||||
const fsEntryStore = {
|
||||
listChildren: async () => [{ uuid: 'child' }],
|
||||
hasChildrenByParentUid: async () => true,
|
||||
} as never;
|
||||
const shaped = await toLegacyEntry(
|
||||
undefined,
|
||||
@@ -373,7 +375,7 @@ describe('toLegacyEntry', () => {
|
||||
|
||||
it('treats a failed child probe as non-empty rather than throwing', async () => {
|
||||
const fsEntryStore = {
|
||||
listChildren: async () => {
|
||||
hasChildrenByParentUid: async () => {
|
||||
throw new Error('db down');
|
||||
},
|
||||
} as never;
|
||||
@@ -385,6 +387,61 @@ 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]' }),
|
||||
@@ -522,3 +579,42 @@ 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);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ 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';
|
||||
@@ -129,7 +130,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.
|
||||
*/
|
||||
async function resolveOrNotFound(
|
||||
export async function resolveOrNotFound(
|
||||
fsEntryStore: FSEntryStore,
|
||||
ref: NodeRef,
|
||||
): Promise<FSEntry> {
|
||||
@@ -229,7 +230,7 @@ export async function assertAccess(
|
||||
aclService: ACLService,
|
||||
_fsService: FSService,
|
||||
actor: Actor,
|
||||
path: string,
|
||||
path: string | ResourceDescriptor,
|
||||
mode: AclMode,
|
||||
): Promise<void> {
|
||||
await aclService.assertFsAccess(actor, path, mode, LEGACY_REFUSAL);
|
||||
@@ -474,39 +475,46 @@ export async function toLegacyEntry(
|
||||
...(opts.isShared === undefined ? {} : { is_shared: opts.isShared }),
|
||||
};
|
||||
|
||||
// `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;
|
||||
}
|
||||
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,
|
||||
),
|
||||
]);
|
||||
|
||||
// `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,
|
||||
);
|
||||
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;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
/*
|
||||
* 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,4 +194,87 @@ 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);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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,7 +20,10 @@
|
||||
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 } from '../../services/profile/ProfileService.js';
|
||||
import type {
|
||||
ProfileService,
|
||||
UserProfile,
|
||||
} from '../../services/profile/ProfileService.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
// Shape only; the store decides whether the account exists.
|
||||
@@ -56,13 +59,21 @@ 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',
|
||||
});
|
||||
}
|
||||
target = await this.stores.user.getByUuid(caller.uuid);
|
||||
// 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;
|
||||
} else {
|
||||
if (
|
||||
typeof username !== 'string' ||
|
||||
@@ -80,6 +91,11 @@ 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,7 +30,8 @@
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { finished } from 'node:stream/promises';
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
afterAll,
|
||||
@@ -1530,25 +1531,34 @@ describe('PuterAIController videoProxy upstream handling', () => {
|
||||
} as unknown as Response);
|
||||
|
||||
const handler = captureGet('/puterai/video/proxy');
|
||||
const { res, captured } = makeRes();
|
||||
// The body is piped, so the response has to be a real stream.
|
||||
const sink: Buffer[] = [];
|
||||
(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;
|
||||
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;
|
||||
|
||||
await handler(makeReq({ query: signedQuery('vid1') }), res);
|
||||
await settleStream();
|
||||
await finished(res as unknown as Writable);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'vid1:download?alt=media&key=gemini-test-key',
|
||||
),
|
||||
{ signal: expect.any(AbortSignal) },
|
||||
);
|
||||
expect(captured.headers['Content-Type']).toBe('video/mp4');
|
||||
expect(headers['Content-Type']).toBe('video/mp4');
|
||||
expect(Buffer.concat(sink).toString('utf8')).toBe('video-bytes');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ 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 {
|
||||
@@ -337,8 +338,10 @@ 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');
|
||||
@@ -351,9 +354,12 @@ export class PuterAIController extends PuterController {
|
||||
res.status(500).send('Empty response body');
|
||||
return;
|
||||
}
|
||||
Readable.fromWeb(
|
||||
upstream.body as unknown as import('node:stream/web').ReadableStream,
|
||||
).pipe(res);
|
||||
void pipeToResponse(
|
||||
Readable.fromWeb(
|
||||
upstream.body as unknown as import('node:stream/web').ReadableStream,
|
||||
),
|
||||
res,
|
||||
);
|
||||
};
|
||||
|
||||
#listModels(driverKey: 'aiChat' | 'aiImage' | 'aiVideo') {
|
||||
@@ -539,7 +545,8 @@ 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(
|
||||
@@ -687,7 +694,8 @@ 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(
|
||||
@@ -741,7 +749,8 @@ export class PuterAIController extends PuterController {
|
||||
text: extractTextContent(
|
||||
(
|
||||
messageResult.message as
|
||||
Record<string, unknown> | undefined
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.content,
|
||||
),
|
||||
index: 0,
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* 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,6 +341,61 @@ 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.
|
||||
|
||||
@@ -287,10 +287,16 @@ export class TeamController extends PuterController {
|
||||
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'),
|
||||
});
|
||||
const result = await this.services.team.provisionAccount(
|
||||
uid,
|
||||
userId,
|
||||
{
|
||||
username: this.#requireString(body.username, 'username'),
|
||||
email: this.#optionalString(body.email, 'email'),
|
||||
},
|
||||
// The owner's request is what the signup gate gets to judge.
|
||||
{ req, fingerprint: req.deviceFingerprint ?? null },
|
||||
);
|
||||
// Shown once; the admin delivers it out of band.
|
||||
res.json({
|
||||
username: result.username,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -14,6 +15,7 @@ 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;
|
||||
@@ -670,6 +672,92 @@ 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,
|
||||
});
|
||||
const seat = vi
|
||||
.spyOn(server.stores.team, 'getOrgSeat')
|
||||
.mockResolvedValue({ require_2fa: 1 } as never);
|
||||
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();
|
||||
@@ -918,7 +1006,7 @@ describe('WebDAVController', () => {
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('rejects delete when ACL denies write access', async () => {
|
||||
it('refuses a delete outside the caller’s tree as not found', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
@@ -935,7 +1023,7 @@ describe('WebDAVController', () => {
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
expect(captured.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1505,7 +1593,7 @@ describe('WebDAVController verbs', () => {
|
||||
expect(captured.body).toBe('Cannot GET a directory');
|
||||
});
|
||||
|
||||
it("denies reading another user's file with 403", async () => {
|
||||
it("hides another user's file as not found", async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
const path = `/${owner.username}/Documents/private.txt`;
|
||||
@@ -1516,8 +1604,8 @@ describe('WebDAVController verbs', () => {
|
||||
path,
|
||||
actor: stranger.actor,
|
||||
});
|
||||
expect(captured.statusCode).toBe(403);
|
||||
expect(captured.body).toBe('Permission denied');
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.body).toBe('Not Found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1805,6 +1893,18 @@ 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', () => {
|
||||
@@ -1837,7 +1937,8 @@ describe('WebDAVController verbs', () => {
|
||||
path: `/${owner.username}/Documents/intruder`,
|
||||
actor: stranger.actor,
|
||||
});
|
||||
expect(captured.statusCode).toBe(403);
|
||||
// The parent is out of sight, so the refusal doesn't confirm it.
|
||||
expect(captured.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2048,7 +2149,7 @@ describe('WebDAVController verbs', () => {
|
||||
},
|
||||
});
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.body).toBe('Source not found');
|
||||
expect(captured.body).toBe('Not Found');
|
||||
});
|
||||
|
||||
it('returns 409 when the destination parent is not a directory', async () => {
|
||||
@@ -2174,7 +2275,7 @@ describe('WebDAVController verbs', () => {
|
||||
},
|
||||
});
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.body).toBe('Source not found');
|
||||
expect(captured.body).toBe('Not Found');
|
||||
});
|
||||
|
||||
it("denies moving into another user's home", async () => {
|
||||
@@ -2192,7 +2293,7 @@ describe('WebDAVController verbs', () => {
|
||||
host: 'dav.puter.localhost',
|
||||
},
|
||||
});
|
||||
expect(captured.statusCode).toBe(403);
|
||||
expect(captured.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2363,6 +2464,7 @@ 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`;
|
||||
@@ -2400,32 +2502,34 @@ describe('WebDAVController verbs', () => {
|
||||
server.services.share.share(owner.actor as never, {
|
||||
uid: dirUuid,
|
||||
recipient: { username: to.username! },
|
||||
mode: 'read',
|
||||
mode,
|
||||
}),
|
||||
);
|
||||
return { dirUuid, dirPath };
|
||||
};
|
||||
|
||||
/**
|
||||
* PROPFIND under the ALS wrap a real request gets, pre-auth actor
|
||||
* A request under the ALS wrap a real request gets, pre-auth actor
|
||||
* empty.
|
||||
*/
|
||||
const propfind = async (path: string, actor: unknown, depth = '1') => {
|
||||
const davRequest = async (
|
||||
method: string,
|
||||
path: string,
|
||||
actor: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
) => {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor: undefined }, () =>
|
||||
dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
path,
|
||||
headers: { depth },
|
||||
actor,
|
||||
}),
|
||||
makeReq({ method, path, headers, 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
|
||||
@@ -2445,6 +2549,40 @@ 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();
|
||||
@@ -2491,7 +2629,7 @@ describe('WebDAVController verbs', () => {
|
||||
expect(xml).not.toContain('Documents');
|
||||
});
|
||||
|
||||
it('keeps a sharer’s collection a 403 for a caller with no share', async () => {
|
||||
it('keeps a sharer’s collection out of sight for a caller with no share', async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
await shareFolder(owner, await makeUser());
|
||||
@@ -2500,7 +2638,8 @@ describe('WebDAVController verbs', () => {
|
||||
`/${owner.username}`,
|
||||
stranger.actor,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
// The same 404 as a name nobody holds, so it confirms no account.
|
||||
expect(captured.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2670,9 +2809,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 a plain 403 again.
|
||||
// And the level it used to stand in for is out of sight again.
|
||||
const owned = await propfind(`/${owner.username}`, holder.actor);
|
||||
expect(owned.statusCode).toBe(403);
|
||||
expect(owned.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses writes and reads aimed at the invented levels', async () => {
|
||||
@@ -2721,4 +2860,257 @@ 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ 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 { makeActor, type Actor } from '../../core/actor.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
assertNotSuspended,
|
||||
@@ -44,12 +44,13 @@ import {
|
||||
Put,
|
||||
Unlock,
|
||||
} from '../../core/http/decorators.js';
|
||||
import type { RouteOptions } from '../../core/http/types.js';
|
||||
import { PREFLIGHT_MAX_AGE, 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';
|
||||
@@ -81,6 +82,7 @@ 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 = {
|
||||
@@ -116,9 +118,6 @@ 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;
|
||||
|
||||
@@ -475,28 +474,19 @@ export class WebDAVController extends PuterController {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
|
||||
// -- OPTIONS ------------------------------------------------------
|
||||
@@ -522,15 +512,15 @@ export class WebDAVController extends PuterController {
|
||||
headOnly: boolean,
|
||||
): Promise<void> {
|
||||
const entry = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (!entry)
|
||||
throw new HttpError(404, 'Not Found', { legacyCode: 'not_found' });
|
||||
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.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;
|
||||
@@ -576,7 +566,7 @@ export class WebDAVController extends PuterController {
|
||||
const depth = req.headers.depth ?? '1';
|
||||
|
||||
if (davPath === '/') {
|
||||
await this.#assertRead(actor, davPath);
|
||||
await this.#assertAccess(actor, davPath, 'read');
|
||||
this.#sendMultistatus(res, await this.#rootPropfind(actor, depth));
|
||||
return;
|
||||
}
|
||||
@@ -598,11 +588,9 @@ export class WebDAVController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
throw new HttpError(404, 'Not Found', { legacyCode: 'not_found' });
|
||||
}
|
||||
if (!entry) throw davNotFound();
|
||||
|
||||
await this.#assertRead(actor, davPath);
|
||||
await this.#assertAccess(actor, entry.path, 'read');
|
||||
|
||||
// `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
|
||||
@@ -704,7 +692,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.#canRead(actor, davPath)) return null;
|
||||
if (await this.#canAccess(actor, entry.path, 'read')) return null;
|
||||
|
||||
const shares = (await this.#sharedWithMe(actor)).filter(
|
||||
(share) => share.owner?.username === owner,
|
||||
@@ -756,17 +744,20 @@ export class WebDAVController extends PuterController {
|
||||
|
||||
async #proppatch(
|
||||
res: Response,
|
||||
{ davPath, lockToken }: DavContext,
|
||||
{ actor, 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(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(maskPathForRequest(davPath)))}</D:href><D:propstat><D:prop/><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response></D:multistatus>`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -795,8 +786,7 @@ export class WebDAVController extends PuterController {
|
||||
throw new HttpError(423, 'Locked', { legacyCode: 'conflict' });
|
||||
}
|
||||
const userId = actor.user!.id as number;
|
||||
const parentPath = pathPosix.dirname(davPath);
|
||||
await this.#assertWrite(actor, parentPath);
|
||||
await this.#assertCreate(actor, davPath);
|
||||
|
||||
const existing = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (existing)
|
||||
@@ -832,8 +822,11 @@ export class WebDAVController extends PuterController {
|
||||
}
|
||||
|
||||
const userId = actor.user!.id as number;
|
||||
const parentPath = pathPosix.dirname(davPath);
|
||||
await this.#assertWrite(actor, parentPath);
|
||||
// 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 contentLength = Number(
|
||||
req.headers['content-length'] ??
|
||||
@@ -845,9 +838,6 @@ 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?.(
|
||||
@@ -892,11 +882,9 @@ 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 new HttpError(404, 'Not Found', { legacyCode: 'not_found' });
|
||||
if (!entry) throw davNotFound();
|
||||
await this.#assertAccess(actor, entry.path, 'write');
|
||||
|
||||
await this.services.fs.remove(userId, { entry, recursive: true });
|
||||
this.#emitGuiEvent('outer.gui.item.removed', entry);
|
||||
@@ -918,14 +906,10 @@ 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 new HttpError(404, 'Source not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
if (!source) throw davNotFound();
|
||||
await this.#assertAccess(actor, source.path, 'read');
|
||||
await this.#assertCreate(actor, destPath);
|
||||
|
||||
const overwrite = req.headers.overwrite !== 'F';
|
||||
const destExists = await this.stores.fsEntry.getEntryByPath(destPath);
|
||||
@@ -970,14 +954,10 @@ 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 new HttpError(404, 'Source not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
if (!source) throw davNotFound();
|
||||
await this.#assertAccess(actor, source.path, 'write');
|
||||
await this.#assertCreate(actor, destPath);
|
||||
|
||||
const overwrite = req.headers.overwrite !== 'F';
|
||||
const destExists = await this.stores.fsEntry.getEntryByPath(destPath);
|
||||
@@ -1022,8 +1002,11 @@ 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.
|
||||
await this.#assertWrite(actor, davPath);
|
||||
// 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);
|
||||
|
||||
const timeoutSeconds = parseLockTimeout(
|
||||
req.headers.timeout as string | undefined,
|
||||
@@ -1156,25 +1139,46 @@ export class WebDAVController extends PuterController {
|
||||
|
||||
// -- ACL helpers -------------------------------------------------
|
||||
|
||||
async #canRead(actor: Actor, path: string): Promise<boolean> {
|
||||
// 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> {
|
||||
const acl = this.services.acl;
|
||||
return await acl.check(actor, acl.fsDescriptor(path), 'read');
|
||||
return await acl.check(actor, acl.fsDescriptor(path), mode);
|
||||
}
|
||||
|
||||
async #assertRead(actor: Actor, path: string): Promise<void> {
|
||||
if (!(await this.#canRead(actor, path)))
|
||||
throw new HttpError(403, 'Permission denied', {
|
||||
legacyCode: 'permission_denied',
|
||||
});
|
||||
/**
|
||||
* 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 #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',
|
||||
});
|
||||
/** 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');
|
||||
}
|
||||
|
||||
// -- Event emission ----------------------------------------------
|
||||
@@ -1268,6 +1272,11 @@ 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 { setupTestServer } from '../../testUtil.js';
|
||||
import { createTestUser, setupTestServer } from '../../testUtil.js';
|
||||
import type { WispController } from './WispController.js';
|
||||
|
||||
let server: PuterServer;
|
||||
@@ -147,26 +147,114 @@ describe('WispController', () => {
|
||||
});
|
||||
|
||||
describe('verify', () => {
|
||||
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 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);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await verifyHandler(makeReq({ body: { token } }), res);
|
||||
|
||||
expect(captured.statusCode).toBe(200);
|
||||
const body = captured.body as { allow: boolean };
|
||||
expect(body.allow).toBe(true);
|
||||
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);
|
||||
});
|
||||
|
||||
it('verifies a valid guest token', async () => {
|
||||
|
||||
@@ -18,7 +18,12 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
@@ -71,12 +76,14 @@ 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' },
|
||||
);
|
||||
@@ -120,13 +127,10 @@ export class WispController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
// 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 user = isGuest ? null : await this.#liveUserFor(decoded);
|
||||
|
||||
// Build policy event — extensions can deny via extension.on('wisp.get-policy')
|
||||
const event: Record<string, unknown> = {
|
||||
allow: true,
|
||||
policy: { allow: true },
|
||||
@@ -145,6 +149,43 @@ 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 ?? {};
|
||||
}
|
||||
|
||||
@@ -81,6 +81,8 @@ 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`. */
|
||||
@@ -111,6 +113,11 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,6 +147,7 @@ export class HttpError extends Error {
|
||||
readonly code?: string;
|
||||
readonly fields?: Record<string, unknown>;
|
||||
readonly noAlarm?: boolean;
|
||||
readonly alarmSeverity?: PagerSeverity;
|
||||
|
||||
constructor(
|
||||
statusCode: number,
|
||||
@@ -156,6 +164,7 @@ 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;
|
||||
|
||||
/** Parsed user-agent, populated by the global UA-parsing middleware. */
|
||||
/** User-agent, parsed from the header on first read. */
|
||||
ua?: {
|
||||
browser: { name?: string; version?: string; major?: string };
|
||||
os: { name?: string; version?: string };
|
||||
|
||||
@@ -175,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`, `onlyoffice`, etc. all bypass.
|
||||
// `puter-app-icons`, etc. all pass through.
|
||||
for (const sub of ['api', 'js', 'docs', 'editor', 'puter-app-icons']) {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
@@ -189,6 +189,17 @@ describe('createUserSubdomainNotFound', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('404s an extension-owned subdomain nobody has claimed', () => {
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
makeReq({
|
||||
subdomains: ['com', 'puter', 'onlyoffice'],
|
||||
host: 'onlyoffice.puter.com',
|
||||
}),
|
||||
);
|
||||
expectNotFound(next);
|
||||
});
|
||||
|
||||
it('passes through when no subdomain is present (root)', () => {
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
@@ -640,6 +651,21 @@ describe('createUserSubdomainNotFound with claimed subdomains', () => {
|
||||
expect(args).toEqual([]);
|
||||
});
|
||||
|
||||
it('hands a main-pipeline claim to the rest of the stack, like a reserved name', async () => {
|
||||
const claims = lookup({ office: { mainPipeline: true } });
|
||||
for (const [host, subdomains] of [
|
||||
['office.puter.com', ['office']],
|
||||
['office.puter.com:4100', ['office']],
|
||||
['office.x.puter.com', ['x', 'office']],
|
||||
] as const) {
|
||||
const args = await runClaimed(
|
||||
createUserSubdomainNotFound(config, claims),
|
||||
hostReq(host, [...subdomains]),
|
||||
);
|
||||
expect(args, host).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('looks the claim up on every request', async () => {
|
||||
const claims: Record<string, ResolvedClaim> = {};
|
||||
const middleware = createUserSubdomainNotFound(config, lookup(claims));
|
||||
@@ -677,7 +703,7 @@ describe('addClaimedSubdomain', () => {
|
||||
});
|
||||
|
||||
it('rejects subdomains core already handles', () => {
|
||||
for (const name of ['www', 'api', 'js', 'dav', 'docs', 'onlyoffice']) {
|
||||
for (const name of ['www', 'api', 'js', 'dav', 'docs']) {
|
||||
expect(() => addClaimedSubdomain(new Map(), name, claim)).toThrow(
|
||||
/reserved/,
|
||||
);
|
||||
@@ -706,6 +732,26 @@ describe('addClaimedSubdomain', () => {
|
||||
).toThrow(/invalid checkRoute/);
|
||||
});
|
||||
|
||||
it('records a main-pipeline claim, and refuses one that also carries a gate', () => {
|
||||
const claims = new Map<string, SubdomainClaim>();
|
||||
addClaimedSubdomain(claims, 'onlyoffice', { mainPipeline: true });
|
||||
expect(claims.get('onlyoffice')).toEqual({ mainPipeline: true });
|
||||
expect(() =>
|
||||
addClaimedSubdomain(claims, 'onlyoffice', { mainPipeline: true }),
|
||||
).toThrow(/already claimed/);
|
||||
for (const gate of [
|
||||
{ authorize: () => true },
|
||||
{ checkRoute: () => undefined },
|
||||
]) {
|
||||
expect(() =>
|
||||
addClaimedSubdomain(new Map(), 'portal', {
|
||||
mainPipeline: true,
|
||||
...gate,
|
||||
} as unknown as SubdomainClaim),
|
||||
).toThrow(/main pipeline with a gate/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a claim without authorize', () => {
|
||||
for (const bad of [undefined, {}, { authorize: true }]) {
|
||||
expect(() =>
|
||||
|
||||
@@ -41,8 +41,9 @@ const NATIVE_APP_SUBDOMAINS = [
|
||||
const NATIVE_APPS_WITH_DIST = new Set(['docs', 'developer']);
|
||||
|
||||
/**
|
||||
* Subdomains that v2 serves itself. Anything NOT in this set that lives on the
|
||||
* root domain is treated as a user-defined site and rejected with a 404.
|
||||
* Subdomains that v2 serves itself. A root-domain subdomain neither in this set
|
||||
* nor claimed by an extension is treated as a user-defined site and rejected
|
||||
* with a 404.
|
||||
*
|
||||
* Kept as a plain Set so `has()` is O(1); order doesn't matter.
|
||||
*/
|
||||
@@ -55,8 +56,6 @@ const RESERVED_SUBDOMAINS = new Set<string>([
|
||||
...NATIVE_APP_SUBDOMAINS,
|
||||
// App-icon serving subdomain.
|
||||
'puter-app-icons',
|
||||
// Extension-owned subdomains.
|
||||
'onlyoffice',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -69,7 +68,8 @@ export type ClaimAuthorize = (
|
||||
res: Response,
|
||||
) => boolean | Promise<boolean>;
|
||||
|
||||
export interface SubdomainClaim {
|
||||
/** A claim answered on its own router, only past `authorize`. */
|
||||
export interface GatedSubdomainClaim {
|
||||
authorize: ClaimAuthorize;
|
||||
/**
|
||||
* Vets each route declared for the claim as the server builds it; a throw
|
||||
@@ -78,12 +78,23 @@ export interface SubdomainClaim {
|
||||
checkRoute?: (options: RouteOptions, label: string) => void;
|
||||
}
|
||||
|
||||
/** A claim as dispatch sees it: its gate, plus the routes served behind it. */
|
||||
export interface ResolvedClaim {
|
||||
authorize: ClaimAuthorize;
|
||||
handler?: RequestHandler;
|
||||
/** Served like core's own subdomains: CORS, auth and every route. */
|
||||
export interface MainPipelineSubdomainClaim {
|
||||
mainPipeline: true;
|
||||
}
|
||||
|
||||
export type SubdomainClaim = GatedSubdomainClaim | MainPipelineSubdomainClaim;
|
||||
|
||||
/** A claim as dispatch sees it: a gate and the routes behind it, or neither. */
|
||||
export type ResolvedClaim =
|
||||
| { authorize: ClaimAuthorize; handler?: RequestHandler }
|
||||
| MainPipelineSubdomainClaim;
|
||||
|
||||
export const isMainPipelineClaim = (
|
||||
claim: SubdomainClaim | ResolvedClaim | undefined,
|
||||
): claim is MainPipelineSubdomainClaim =>
|
||||
(claim as { mainPipeline?: unknown } | undefined)?.mainPipeline === true;
|
||||
|
||||
export type ClaimLookup = (name: string) => ResolvedClaim | undefined;
|
||||
|
||||
const SUBDOMAIN_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
@@ -106,6 +117,15 @@ export const addClaimedSubdomain = (
|
||||
if (claims.has(name)) {
|
||||
throw new Error(`Subdomain '${name}' is already claimed`);
|
||||
}
|
||||
if (isMainPipelineClaim(claim)) {
|
||||
if ('authorize' in claim || 'checkRoute' in claim) {
|
||||
throw new Error(
|
||||
`Subdomain '${name}' claimed for the main pipeline with a gate`,
|
||||
);
|
||||
}
|
||||
claims.set(name, { mainPipeline: true });
|
||||
return;
|
||||
}
|
||||
if (typeof claim?.authorize !== 'function') {
|
||||
throw new Error(`Subdomain '${name}' claimed without authorize`);
|
||||
}
|
||||
@@ -131,7 +151,7 @@ const subdomainNotFound = () =>
|
||||
* unclaimed-subdomain 404, and errors reach the error handler.
|
||||
*/
|
||||
const dispatchClaimed = async (
|
||||
claim: ResolvedClaim,
|
||||
claim: Exclude<ResolvedClaim, MainPipelineSubdomainClaim>,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
@@ -190,7 +210,8 @@ export const createWwwRedirect = (config: IConfig): RequestHandler => {
|
||||
* Passes through when:
|
||||
*
|
||||
* - No active subdomain (root)
|
||||
* - Active subdomain is reserved (api, js, native apps, …)
|
||||
* - Active subdomain is reserved (api, js, native apps, …) or claimed for the
|
||||
* main pipeline
|
||||
* - Host is on one of the hosting domains (they may nest under `config.domain`)
|
||||
* - Host is a local worker host and the local worker server is on
|
||||
* - Host isn't under `config.domain` (custom domains, other hosts)
|
||||
@@ -223,6 +244,8 @@ export const createUserSubdomainNotFound = (
|
||||
req.subdomains?.[req.subdomains.length - 1] ?? ''
|
||||
).toLowerCase();
|
||||
if (active === '' || RESERVED_SUBDOMAINS.has(active)) return next();
|
||||
const claim = lookupClaim(active);
|
||||
if (isMainPipelineClaim(claim)) return next();
|
||||
|
||||
const hostName = (req.headers.host ?? '').toLowerCase().split(':')[0];
|
||||
if (
|
||||
@@ -240,7 +263,7 @@ export const createUserSubdomainNotFound = (
|
||||
// included. One label deep only: `<name>.x.<domain>` is not the claim.
|
||||
const claimed =
|
||||
req.subdomains?.length === 1 && hostName === `${active}.${domain}`
|
||||
? lookupClaim(active)
|
||||
? claim
|
||||
: undefined;
|
||||
if (claimed) {
|
||||
void dispatchClaimed(claimed, req, res, next);
|
||||
|
||||
@@ -669,7 +669,7 @@ describe('resolveOwnedAppForHostedSite', () => {
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
site: { user_id: owner.id },
|
||||
db: server.clients.db,
|
||||
appStore: server.stores.app,
|
||||
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 },
|
||||
db: server.clients.db,
|
||||
appStore: server.stores.app,
|
||||
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 },
|
||||
db: server.clients.db,
|
||||
appStore: server.stores.app,
|
||||
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 },
|
||||
db: server.clients.db,
|
||||
appStore: server.stores.app,
|
||||
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 },
|
||||
db: server.clients.db,
|
||||
appStore: server.stores.app,
|
||||
config: baseConfig(),
|
||||
requirePrivate: true,
|
||||
});
|
||||
@@ -753,18 +753,54 @@ describe('resolveOwnedAppForHostedSite', () => {
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.app.puter.localhost'),
|
||||
site: { user_id: owner.id },
|
||||
db: server.clients.db,
|
||||
appStore: server.stores.app,
|
||||
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 },
|
||||
db: server.clients.db,
|
||||
appStore: server.stores.app,
|
||||
config: baseConfig(),
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
@@ -774,7 +810,7 @@ describe('resolveOwnedAppForHostedSite', () => {
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf(''),
|
||||
site: { user_id: 1 },
|
||||
db: server.clients.db,
|
||||
appStore: server.stores.app,
|
||||
config: baseConfig(),
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
* [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 type { Actor } from '../../actor';
|
||||
|
||||
@@ -193,7 +193,7 @@ export function subdomainFromHost(
|
||||
export async function resolveOwnedAppForHostedSite(opts: {
|
||||
req: Request;
|
||||
site: SubdomainLike;
|
||||
db: AbstractDatabaseClient;
|
||||
appStore: Pick<AppStore, 'listOwnedByIndexUrlCandidates'>;
|
||||
config: PrivateHostingConfig;
|
||||
requirePrivate?: boolean;
|
||||
}): Promise<AppLike | null> {
|
||||
@@ -247,15 +247,11 @@ 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 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],
|
||||
const rows = await opts.appStore.listOwnedByIndexUrlCandidates(
|
||||
opts.site.user_id,
|
||||
uniqueCandidates,
|
||||
{ requirePrivate: opts.requirePrivate },
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
if (rows.length > 1) {
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Writable } from 'node:stream';
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { Readable, 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';
|
||||
@@ -654,6 +655,66 @@ 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}`;
|
||||
@@ -1217,6 +1278,50 @@ 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:
|
||||
@@ -1462,6 +1567,86 @@ 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', () => {
|
||||
@@ -1824,3 +2009,463 @@ 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,6 +26,7 @@ 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,
|
||||
@@ -94,6 +95,20 @@ 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);
|
||||
@@ -255,18 +270,32 @@ 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 associatedApp = (await resolveOwnedAppForHostedSite({
|
||||
req,
|
||||
site: { user_id: site.user_id },
|
||||
db: layers.clients.db,
|
||||
config: hostingCfg,
|
||||
})) as AppRow | null;
|
||||
const appLookup = awaitLater(
|
||||
resolveOwnedAppForHostedSite({
|
||||
req,
|
||||
site: { user_id: site.user_id },
|
||||
appStore: layers.stores.app,
|
||||
config: hostingCfg,
|
||||
}),
|
||||
);
|
||||
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 isPrivateApp = Boolean(associatedApp?.is_private);
|
||||
const privateApp = isPrivateApp ? associatedApp : null;
|
||||
@@ -495,9 +524,7 @@ export const createPuterSiteMiddleware = (
|
||||
return;
|
||||
}
|
||||
|
||||
const rootEntry = await layers.stores.fsEntry.getEntryById(
|
||||
site.root_dir_id,
|
||||
);
|
||||
const rootEntry = await rootLookup;
|
||||
if (!rootEntry) {
|
||||
res.status(404)
|
||||
.type('text/html; charset=UTF-8')
|
||||
@@ -579,24 +606,6 @@ 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.
|
||||
@@ -620,6 +629,9 @@ 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
|
||||
@@ -629,6 +641,21 @@ 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
|
||||
@@ -640,7 +667,11 @@ export const createPuterSiteMiddleware = (
|
||||
const candidate = await layers.stores.fsEntry.getEntryByPath(
|
||||
errorTarget.absPath,
|
||||
);
|
||||
if (candidate && !candidate.isDir) {
|
||||
if (
|
||||
candidate &&
|
||||
!candidate.isDir &&
|
||||
isServableEntryPath(candidate.path, rootPath)
|
||||
) {
|
||||
entry = candidate;
|
||||
statusOverride = errorTarget.status;
|
||||
}
|
||||
|
||||
@@ -115,6 +115,31 @@ async function checkMemory(key, limit, windowMs) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend up to `units` from one bucket at once and report how many fit: the
|
||||
* many-unit form of `checkMemory`, for a caller that would otherwise loop.
|
||||
*/
|
||||
async function claimMemory(key, limit, windowMs, units) {
|
||||
const now = Date.now();
|
||||
let entry = memoryWindows.get(key);
|
||||
if (!entry) {
|
||||
if (memoryWindows.size >= MEMORY_MAX_KEYS) {
|
||||
const oldest = memoryWindows.keys().next().value;
|
||||
memoryWindows.delete(oldest);
|
||||
}
|
||||
entry = { ts: [], windowMs };
|
||||
memoryWindows.set(key, entry);
|
||||
} else {
|
||||
entry.windowMs = windowMs;
|
||||
}
|
||||
const timestamps = entry.ts;
|
||||
const cutoff = now - windowMs;
|
||||
while (timestamps.length > 0 && timestamps[0] < cutoff) timestamps.shift();
|
||||
const admitted = Math.max(0, Math.min(units, limit - timestamps.length));
|
||||
for (let i = 0; i < admitted; i++) timestamps.push(now);
|
||||
return admitted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a bucket's state without spending from it. For gates whose budget is
|
||||
* consumed by something other than the request being admitted — a failed
|
||||
@@ -182,6 +207,32 @@ async function checkRedis(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `checkRedis` for many units in one round trip: add them all, count, and take
|
||||
* back however many landed over the limit. Same add-then-count shape, so
|
||||
* concurrent claims can under-admit but never over-admit.
|
||||
*/
|
||||
async function claimRedis(redis, key, limit, windowMs, units) {
|
||||
const redisKey = `rate:${key}`;
|
||||
const now = Date.now();
|
||||
const members = Array.from(
|
||||
{ length: units },
|
||||
() => `${now}:${crypto.randomUUID()}`,
|
||||
);
|
||||
const results = await redis
|
||||
.multi()
|
||||
.zremrangebyscore(redisKey, 0, now - windowMs)
|
||||
.zadd(redisKey, ...members.flatMap((member) => [now, member]))
|
||||
.zcard(redisKey)
|
||||
.pexpire(redisKey, windowMs)
|
||||
.exec();
|
||||
|
||||
const count = Number(multiResult(results, 2));
|
||||
const excess = Math.min(units, Math.max(0, count - limit));
|
||||
if (excess > 0) await redis.zrem(redisKey, ...members.slice(-excess));
|
||||
return units - excess;
|
||||
}
|
||||
|
||||
async function peekRedis(redis, key, limit, windowMs) {
|
||||
const redisKey = `rate:${key}`;
|
||||
const results = await redis
|
||||
@@ -218,6 +269,31 @@ async function checkKv(kv, key, limit, windowMs) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// `checkKv` for many units: one read, then a row per admitted unit. Newest
|
||||
// first for the same reason `checkKv` reads that way.
|
||||
async function claimKv(kv, key, limit, windowMs, units) {
|
||||
const prefix = `rate:${key}:`;
|
||||
const { res } = await kv.list({
|
||||
as: 'keys',
|
||||
pattern: prefix,
|
||||
limit: limit + 1,
|
||||
reverse: true,
|
||||
});
|
||||
const keys = Array.isArray(res) ? res : (res?.items ?? []);
|
||||
const admitted = Math.max(0, Math.min(units, limit - keys.length));
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
Array.from({ length: admitted }, () =>
|
||||
kv.set({
|
||||
key: `${prefix}${now}:${crypto.randomUUID()}`,
|
||||
value: 1,
|
||||
expireAt: Math.ceil((now + windowMs) / 1000),
|
||||
}),
|
||||
),
|
||||
);
|
||||
return admitted;
|
||||
}
|
||||
|
||||
// `list` filters by TTL, so a non-expired row is in-window — same read
|
||||
// `checkKv` does, minus the write.
|
||||
async function peekKv(kv, key, limit) {
|
||||
@@ -395,6 +471,10 @@ function instrumentBackendPair(name, pair) {
|
||||
withSpan('rate_limit.peek', attrs, () =>
|
||||
pair.peek(key, limit, windowMs),
|
||||
),
|
||||
claim: (key, limit, windowMs, units) =>
|
||||
withSpan('rate_limit.claim', attrs, () =>
|
||||
pair.claim(key, limit, windowMs, units),
|
||||
),
|
||||
acquire: (key, limit) =>
|
||||
withSpan('rate_limit.acquire', attrs, () =>
|
||||
pair.acquire(key, limit),
|
||||
@@ -405,6 +485,7 @@ function instrumentBackendPair(name, pair) {
|
||||
const memoryBackendPair = instrumentBackendPair('memory', {
|
||||
rate: checkMemory,
|
||||
peek: peekMemory,
|
||||
claim: claimMemory,
|
||||
acquire: acquireMemoryConcurrent,
|
||||
});
|
||||
|
||||
@@ -453,6 +534,8 @@ export function configureRateLimit({
|
||||
checkRedis(redis, key, limit, windowMs),
|
||||
peek: (key, limit, windowMs) =>
|
||||
peekRedis(redis, key, limit, windowMs),
|
||||
claim: (key, limit, windowMs, units) =>
|
||||
claimRedis(redis, key, limit, windowMs, units),
|
||||
acquire: (key, limit) => acquireRedisConcurrent(redis, key, limit),
|
||||
});
|
||||
}
|
||||
@@ -460,6 +543,8 @@ export function configureRateLimit({
|
||||
backends.kv = instrumentBackendPair('kv', {
|
||||
rate: (key, limit, windowMs) => checkKv(kv, key, limit, windowMs),
|
||||
peek: (key, limit) => peekKv(kv, key, limit),
|
||||
claim: (key, limit, windowMs, units) =>
|
||||
claimKv(kv, key, limit, windowMs, units),
|
||||
acquire: (key, limit) => acquireKvConcurrent(kv, key, limit),
|
||||
});
|
||||
}
|
||||
@@ -770,6 +855,25 @@ export async function checkRateLimit(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `checkRateLimit` for `units` hits at once, in one backend round trip.
|
||||
* Resolves to how many were admitted (0..units) and spent; the rest were not
|
||||
* charged. Fails open, admitting every unit, like the rest of this module.
|
||||
*/
|
||||
export async function claimRateLimit(key, limit, windowMs, units, backend) {
|
||||
if (!(units > 0)) return 0;
|
||||
const bk = resolveBackend(backend);
|
||||
try {
|
||||
return await bk.claim(key, limit, windowMs, units);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[rate-limit] imperative claim failed, failing open:',
|
||||
err,
|
||||
);
|
||||
return units;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge a route-shaped rate-limit spec from inside a handler.
|
||||
*
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
acquireDriverConcurrent,
|
||||
checkDriverRateLimit,
|
||||
checkRateLimit,
|
||||
claimRateLimit,
|
||||
concurrencyGate,
|
||||
consumeRouteRateLimit,
|
||||
configureRateLimit,
|
||||
@@ -1479,6 +1480,20 @@ describe('rate + concurrent limiting — kv backend', () => {
|
||||
await Promise.all(held.map((slot) => slot.release()));
|
||||
});
|
||||
|
||||
it('claims many units in one call, admitting only what fits', async () => {
|
||||
const key = uniqueScope();
|
||||
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(3);
|
||||
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(2);
|
||||
expect(await claimRateLimit(key, 5, 60_000, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('claims against a full bucket as full while expired rows are unswept', async () => {
|
||||
const key = uniqueScope();
|
||||
await seedUnswept(`rate:${key}:`, 3);
|
||||
expect(await claimRateLimit(key, 3, 60_000, 3)).toBe(3);
|
||||
expect(await claimRateLimit(key, 3, 60_000, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('holds a concurrent slot until it is released', async () => {
|
||||
const req = { ip: '9.9.9.9', headers: {}, actor: undefined };
|
||||
const opts = { limit: 1, backend: 'kv', scope: uniqueScope() };
|
||||
@@ -1994,3 +2009,59 @@ describe('acquireConcurrent (imperative)', () => {
|
||||
configureRateLimit();
|
||||
});
|
||||
});
|
||||
|
||||
describe('claimRateLimit', () => {
|
||||
const uniqueKey = () => `claim-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
describe.each([
|
||||
['memory', () => configureRateLimit()],
|
||||
[
|
||||
'redis',
|
||||
() => configureRateLimit({ default: 'redis', redis: new RedisMock() }),
|
||||
],
|
||||
])('%s backend', (_name, configure) => {
|
||||
beforeEach(configure);
|
||||
|
||||
it('admits every unit while under the limit', async () => {
|
||||
expect(await claimRateLimit(uniqueKey(), 5, 60_000, 3)).toBe(3);
|
||||
});
|
||||
|
||||
it('admits exactly up to the limit and spends only that', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(3);
|
||||
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(2);
|
||||
// Nothing spent past the limit, so the bucket is exactly full.
|
||||
expect(await checkRateLimit(key, 5, 60_000)).toBe(false);
|
||||
expect(await checkRateLimit(key, 6, 60_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('admits nothing once the bucket is full', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimRateLimit(key, 2, 60_000, 2)).toBe(2);
|
||||
expect(await claimRateLimit(key, 2, 60_000, 4)).toBe(0);
|
||||
});
|
||||
|
||||
it('shares its bucket with checkRateLimit', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await checkRateLimit(key, 3, 60_000)).toBe(true);
|
||||
expect(await claimRateLimit(key, 3, 60_000, 5)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('claims nothing for zero units', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimRateLimit(key, 1, 60_000, 0)).toBe(0);
|
||||
expect(await checkRateLimit(key, 1, 60_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('fails open, admitting every unit, when the backend throws', async () => {
|
||||
const redis = new RedisMock();
|
||||
redis.multi = () => {
|
||||
throw new Error('redis down');
|
||||
};
|
||||
configureRateLimit({ default: 'redis', redis });
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(await claimRateLimit(uniqueKey(), 1, 60_000, 4)).toBe(4);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -100,12 +100,14 @@ 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]);
|
||||
@@ -144,7 +146,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.
|
||||
@@ -177,7 +179,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',
|
||||
@@ -210,7 +212,9 @@ 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);
|
||||
@@ -220,13 +224,15 @@ 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);
|
||||
@@ -266,7 +272,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(
|
||||
@@ -278,7 +284,63 @@ describe('userProtected — verifyIdentity (step 3)', () => {
|
||||
expect((arg as HttpError).legacyCode).toBe('password_mismatch');
|
||||
});
|
||||
|
||||
it("returns 403 password_required when password account submits no credentials", async () => {
|
||||
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 () => {
|
||||
// No password in body, no revalidation cookie → reject.
|
||||
const user = await makeUserWithPassword('hunter2');
|
||||
const [, , verifyIdentity] = buildGate();
|
||||
@@ -288,7 +350,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', {
|
||||
@@ -347,7 +409,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(
|
||||
@@ -415,10 +477,12 @@ 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 = ?',
|
||||
@@ -434,6 +498,8 @@ 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,6 +21,7 @@ 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';
|
||||
@@ -48,7 +49,9 @@ 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
|
||||
@@ -138,6 +141,11 @@ 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 = {},
|
||||
@@ -219,6 +227,23 @@ 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(
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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,7 +28,12 @@ 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 =
|
||||
@@ -334,6 +339,9 @@ 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,
|
||||
@@ -359,7 +367,8 @@ type AuthImplyingOptions =
|
||||
| { requireCardVerified: true }
|
||||
| {
|
||||
requireAnyVerified:
|
||||
readonly VerificationFactor[] | VerificationFactor[];
|
||||
| readonly VerificationFactor[]
|
||||
| VerificationFactor[];
|
||||
}
|
||||
| { requireSubscription: true | readonly string[] | string[] }
|
||||
| { requireReputation: string };
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -537,6 +537,82 @@ 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 () => {
|
||||
|
||||
@@ -993,3 +993,129 @@ 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,6 +40,12 @@ 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';
|
||||
@@ -52,6 +58,7 @@ 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
|
||||
@@ -143,6 +150,7 @@ 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)`,
|
||||
@@ -475,6 +483,366 @@ 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.
|
||||
@@ -712,6 +1080,25 @@ 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,19 +20,17 @@
|
||||
/**
|
||||
* 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 {
|
||||
@@ -256,7 +254,10 @@ 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(() =>
|
||||
@@ -290,10 +291,15 @@ 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 () => {
|
||||
@@ -368,7 +374,10 @@ 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);
|
||||
@@ -617,7 +626,11 @@ 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,
|
||||
@@ -633,7 +646,11 @@ 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);
|
||||
|
||||
@@ -751,7 +768,10 @@ 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(() =>
|
||||
@@ -786,7 +806,10 @@ 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')
|
||||
@@ -813,62 +836,102 @@ 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 => {
|
||||
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 () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
id: 'capme',
|
||||
aliases: [],
|
||||
id: 'request-fee',
|
||||
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,
|
||||
max_tokens: 1000,
|
||||
costs: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
request: 1_000_001,
|
||||
},
|
||||
},
|
||||
]);
|
||||
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 () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([{
|
||||
id: 'request-fee', costs_currency: 'usd-cents', max_tokens: 1000,
|
||||
costs: { input_tokens: 1, output_tokens: 1, request: 1_000_001 },
|
||||
}]);
|
||||
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 });
|
||||
const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete');
|
||||
await expect(withTestActor(() => d.complete({ model: 'request-fee', messages: [{ role: 'user', content: 'hi' }] })))
|
||||
.rejects.toMatchObject({ statusCode: 402 });
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
d.complete({
|
||||
model: 'request-fee',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(completeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -889,7 +952,10 @@ 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');
|
||||
|
||||
@@ -927,23 +993,31 @@ 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);
|
||||
@@ -963,12 +1037,13 @@ 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('rejects with 402 when a low balance cannot cover a tool\'s extraCost reservation', async () => {
|
||||
it("caps `max_tokens` and the hold at the provider's requestPricing maxTokens", async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
id: 'capme',
|
||||
@@ -980,16 +1055,87 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
]);
|
||||
const d = await makeDriver();
|
||||
|
||||
(FakeChatProvider.prototype as unknown as {
|
||||
requestPricing: NonNullable<IChatProvider['requestPricing']>;
|
||||
}).requestPricing = () => ({ extraCost: 1_000_000 });
|
||||
(
|
||||
FakeChatProvider.prototype as unknown as {
|
||||
requestPricing: NonNullable<IChatProvider['requestPricing']>;
|
||||
}
|
||||
).requestPricing = () => ({ maxTokens: 100 });
|
||||
|
||||
try {
|
||||
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({
|
||||
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({
|
||||
balance: 100,
|
||||
held: 0,
|
||||
});
|
||||
const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete');
|
||||
const completeSpy = vi.spyOn(
|
||||
FakeChatProvider.prototype,
|
||||
'complete',
|
||||
);
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
@@ -999,11 +1145,15 @@ 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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1011,14 +1161,20 @@ 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);
|
||||
@@ -1103,7 +1259,10 @@ 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',
|
||||
@@ -1134,7 +1293,10 @@ 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',
|
||||
@@ -1173,7 +1335,10 @@ 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(() =>
|
||||
@@ -1303,9 +1468,10 @@ 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',
|
||||
@@ -1323,8 +1489,7 @@ 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();
|
||||
@@ -1370,7 +1535,10 @@ 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: {},
|
||||
@@ -1672,9 +1840,7 @@ 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 () => {
|
||||
@@ -1733,8 +1899,11 @@ 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);
|
||||
});
|
||||
@@ -1829,8 +1998,9 @@ 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.
|
||||
@@ -2020,8 +2190,9 @@ 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',
|
||||
@@ -2034,8 +2205,11 @@ 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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
isUpstreamConnectionError,
|
||||
isUpstreamTimeoutError,
|
||||
sanitizeUpstreamMessage,
|
||||
UpstreamDeadlineError,
|
||||
} from '../util/upstreamErrors.js';
|
||||
import { AlibabaProvider } from './providers/alibaba/AlibabaProvider.js';
|
||||
import { AzureChatProvider } from './providers/azure/AzureChatProvider.js';
|
||||
@@ -102,6 +103,7 @@ import {
|
||||
costKeys,
|
||||
costsForRequest,
|
||||
isFreeModel,
|
||||
listedCosts,
|
||||
longContextMultipliers,
|
||||
trackedOutputTokens,
|
||||
usageCost,
|
||||
@@ -309,10 +311,17 @@ const isRateLimit = (a: ProviderAttempt) =>
|
||||
a.status === 429 ||
|
||||
/rate[\s_-]?limit|too many requests|quota/i.test(a.error);
|
||||
|
||||
const isModelAccessDenied = (a: ProviderAttempt) =>
|
||||
a.status === 403 && a.code === 'model_not_found';
|
||||
|
||||
const isModelUnavailable = (a: ProviderAttempt) =>
|
||||
a.status === 404 || isModelAccessDenied(a);
|
||||
|
||||
const isAuthFailure = (a: ProviderAttempt) =>
|
||||
a.status === 401 ||
|
||||
a.status === 403 ||
|
||||
/unauthorized|forbidden|invalid api key/i.test(a.error);
|
||||
!isModelAccessDenied(a) &&
|
||||
(a.status === 401 ||
|
||||
a.status === 403 ||
|
||||
/unauthorized|forbidden|invalid api key/i.test(a.error));
|
||||
|
||||
const isUpstream5xx = (a: ProviderAttempt) =>
|
||||
(a.status !== undefined && a.status >= 500) ||
|
||||
@@ -333,7 +342,7 @@ const isUpstream5xx = (a: ProviderAttempt) =>
|
||||
*/
|
||||
const isRouteLevelFailure = (a: ProviderAttempt) =>
|
||||
a.status === undefined ||
|
||||
a.status === 404 ||
|
||||
isModelUnavailable(a) ||
|
||||
a.status === 408 ||
|
||||
isCreditExhaustion(a) ||
|
||||
isRateLimit(a) ||
|
||||
@@ -373,8 +382,6 @@ const requestLevelError = (a: ProviderAttempt): HttpError =>
|
||||
const isOverloaded = (a: ProviderAttempt) =>
|
||||
a.status === 529 || a.upstreamType === 'overloaded_error';
|
||||
|
||||
const isModelUnavailable = (a: ProviderAttempt) => a.status === 404;
|
||||
|
||||
/**
|
||||
* The HTTP status an in-band mid-stream error chunk reports — a stream
|
||||
* populator failure never goes through `classifyAttempts` (there is no fallback
|
||||
@@ -404,11 +411,12 @@ const streamErrorStatus = (a: ProviderAttempt): number => {
|
||||
* - All credit-exhausted → 503 `upstream_credits_exhausted` (alerted)
|
||||
* - All rate-limited → 429 `upstream_rate_limited` (alerted, unless every attempt
|
||||
* was on a free model — see `allModelsFree`)
|
||||
* - All auth failures → 500 `upstream_auth_failed` (paged: our config)
|
||||
* - All auth failures, or any alongside 403 `model_not_found` → 500
|
||||
* `upstream_auth_failed` (paged: our config)
|
||||
* - All overloaded (529 / `overloaded_error`) → 400
|
||||
* `upstream_provider_unavailable`, `upstreamStatus: 529` (no page)
|
||||
* - All model-not-found (404) → 400 `upstream_bad_request`, `upstreamStatus: 404`
|
||||
* (no page)
|
||||
* - All model-not-found (404, or 403 `model_not_found`) → 400
|
||||
* `upstream_bad_request`, `upstreamStatus: 404` (no page)
|
||||
* - All upstream 5xx or dropped connections → 400 `upstream_provider_unavailable`
|
||||
* (no page)
|
||||
* - All upstream 4xx (other) → 400 `upstream_bad_request` (no page)
|
||||
@@ -449,7 +457,10 @@ const classifyAttempts = (
|
||||
noAlarm: allModelsFree,
|
||||
});
|
||||
}
|
||||
if (attempts.every(isAuthFailure)) {
|
||||
if (
|
||||
attempts.some(isAuthFailure) &&
|
||||
attempts.every((a) => isAuthFailure(a) || isModelAccessDenied(a))
|
||||
) {
|
||||
return new HttpError(500, 'AI provider authentication failed', {
|
||||
legacyCode: 'upstream_auth_failed',
|
||||
fields,
|
||||
@@ -462,9 +473,16 @@ const classifyAttempts = (
|
||||
});
|
||||
}
|
||||
if (attempts.every(isModelUnavailable)) {
|
||||
return new HttpError(400, attempts[0].error, {
|
||||
const accessDenied = attempts.some(isModelAccessDenied);
|
||||
const message = accessDenied
|
||||
? `Model not available: ${attempts[0].model}`
|
||||
: attempts[0].error;
|
||||
return new HttpError(400, message, {
|
||||
legacyCode: 'upstream_bad_request',
|
||||
fields: { ...fields, upstreamStatus: 404 },
|
||||
// Our provider account lost access to the model; low alarm so
|
||||
// someone notices.
|
||||
...(accessDenied ? { alarmSeverity: 'info' as const } : {}),
|
||||
});
|
||||
}
|
||||
if (
|
||||
@@ -600,6 +618,10 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
seen.add(model.id);
|
||||
return true;
|
||||
})
|
||||
.map((model) => {
|
||||
const costs = listedCosts(model);
|
||||
return costs === model.costs ? model : { ...model, costs };
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.provider === b.provider) return a.id.localeCompare(b.id);
|
||||
return a.provider!.localeCompare(b.provider!);
|
||||
@@ -619,7 +641,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
for (const [costKey, raw] of Object.entries(
|
||||
model.costs ?? {},
|
||||
model.costs ? listedCosts(model) : {},
|
||||
)) {
|
||||
// `tokens` is a scale descriptor ("costs expressed per N
|
||||
// tokens"), not a real per-operation cost — skip it.
|
||||
@@ -784,12 +806,15 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
let fundsCap: number | undefined;
|
||||
// The cost factor the current attempt's charges are recorded at.
|
||||
let costFactor = 1;
|
||||
// The instant the current attempt is priced at, from its hold to its
|
||||
// charge, so a rate change mid-request can't split the two.
|
||||
let pricingTime = new Date();
|
||||
if (!useFakeProvider) {
|
||||
({ hold, fundsCap, costFactor } = await this.#applyCreditGate(
|
||||
actor,
|
||||
model,
|
||||
args,
|
||||
{ promptTokenEstimate, requestedMaxTokens },
|
||||
{ promptTokenEstimate, requestedMaxTokens, pricingTime },
|
||||
));
|
||||
}
|
||||
|
||||
@@ -822,7 +847,12 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
const attempt = toAttempt(failed.id, failed.provider!, err);
|
||||
attempts.push(attempt);
|
||||
if (!isFreeModel(failed)) allModelsFree = false;
|
||||
if (isRouteLevelFailure(attempt)) {
|
||||
// A request that outlived its deadline says nothing about the
|
||||
// route's health.
|
||||
if (
|
||||
isRouteLevelFailure(attempt) &&
|
||||
!(err instanceof UpstreamDeadlineError)
|
||||
) {
|
||||
markRouteUnhealthy(
|
||||
failed.provider!,
|
||||
failed.id,
|
||||
@@ -848,6 +878,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
provider: attemptModel.provider,
|
||||
},
|
||||
this.#catalogEntries.get(attemptModel),
|
||||
pricingTime,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -908,11 +939,13 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
// be capped against what is actually left.
|
||||
// The previous attempt released its hold when it failed, so
|
||||
// this one starts from nothing held.
|
||||
pricingTime = new Date();
|
||||
if (!useFakeProvider) {
|
||||
({ hold, fundsCap, costFactor } =
|
||||
await this.#applyCreditGate(actor, fallback, args, {
|
||||
promptTokenEstimate,
|
||||
requestedMaxTokens,
|
||||
pricingTime,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1015,7 +1048,12 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
const usageCosts = chatStream.usageCosts ?? undefined;
|
||||
const enrichedUsage = usage ? { ...usage } : usage;
|
||||
if (enrichedUsage) {
|
||||
this.#injectUsdCents(enrichedUsage, model, usageCosts);
|
||||
this.#injectUsdCents(
|
||||
enrichedUsage,
|
||||
model,
|
||||
usageCosts,
|
||||
pricingTime,
|
||||
);
|
||||
// An empty report recorded nothing; the stream is charged
|
||||
// an estimate once it ends instead.
|
||||
if (Object.values(usage!).some(Number.isFinite)) {
|
||||
@@ -1047,6 +1085,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
model,
|
||||
intendedProvider,
|
||||
usageCosts,
|
||||
pricingTime,
|
||||
});
|
||||
}
|
||||
// Fill in sensible defaults when the provider reported no
|
||||
@@ -1126,6 +1165,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
completionId,
|
||||
username,
|
||||
intendedProvider,
|
||||
pricingTime,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
@@ -1178,7 +1218,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
|
||||
const usageCosts = (res as IChatMessageResult).usageCosts;
|
||||
if ('usage' in res && res.usage) {
|
||||
this.#injectUsdCents(res.usage, model, usageCosts);
|
||||
this.#injectUsdCents(res.usage, model, usageCosts, pricingTime);
|
||||
this.#emitCostCalculated({
|
||||
completionId,
|
||||
username,
|
||||
@@ -1186,6 +1226,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
model,
|
||||
intendedProvider,
|
||||
usageCosts,
|
||||
pricingTime,
|
||||
});
|
||||
res.usage.cost = charged();
|
||||
}
|
||||
@@ -1361,15 +1402,19 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
* inherit the ceiling computed at an expensive model's price.
|
||||
*/
|
||||
requestedMaxTokens: number | undefined;
|
||||
/** The instant this attempt is priced at. */
|
||||
pricingTime: Date;
|
||||
},
|
||||
): Promise<{ hold: CreditHold; fundsCap?: number; costFactor: number }> {
|
||||
const metering = this.services.metering;
|
||||
const { promptTokenEstimate, requestedMaxTokens } = estimates;
|
||||
const { promptTokenEstimate, requestedMaxTokens, pricingTime } =
|
||||
estimates;
|
||||
// A provider can name its own cost keys for this request (Claude's
|
||||
// fast-mode rate card) and size an extra reservation the per-token
|
||||
// math can't see (a web-search/advisor tool that could run several
|
||||
// times before the model answers) — the same clamps it applies when
|
||||
// building the request, so the hold and the request agree.
|
||||
// fast-mode rate card), cap its output, and size an extra reservation
|
||||
// the per-token math can't see (a web-search/advisor tool that could
|
||||
// run several times before the model answers) — the same clamps it
|
||||
// applies when building the request, so the hold and the request
|
||||
// agree.
|
||||
const pricing =
|
||||
this.#providers[model.provider!]?.requestPricing?.(args, model, {
|
||||
promptTokenEstimate,
|
||||
@@ -1387,7 +1432,11 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
actor,
|
||||
this.#meteringModelKey(model),
|
||||
);
|
||||
const requestCosts = costsForRequest(model, promptTokenEstimate);
|
||||
const requestCosts = costsForRequest(
|
||||
model,
|
||||
promptTokenEstimate,
|
||||
pricingTime,
|
||||
);
|
||||
// `|| 0` also catches NaN from a malformed cost table.
|
||||
const inputTokenCost =
|
||||
(Number(requestCosts[inputKey] ?? 0) || 0) *
|
||||
@@ -1433,6 +1482,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
const limit = Math.min(
|
||||
requestedMaxTokens ?? Number.POSITIVE_INFINITY,
|
||||
outputCeiling(model, promptTokenEstimate),
|
||||
positiveOrInfinity(pricing.maxTokens),
|
||||
);
|
||||
const cap = Math.floor(
|
||||
Math.min(limit, affordableOutputTokens(remainingCredits)),
|
||||
@@ -1521,6 +1571,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
completionId: string;
|
||||
username?: string;
|
||||
intendedProvider: string;
|
||||
pricingTime: Date;
|
||||
}): void {
|
||||
const {
|
||||
actor,
|
||||
@@ -1530,6 +1581,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
completionId,
|
||||
username,
|
||||
intendedProvider,
|
||||
pricingTime,
|
||||
} = params;
|
||||
|
||||
const outputTokens = estimateOutputTokens(chatStream.outputChars ?? 0);
|
||||
@@ -1542,7 +1594,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
[outputKey]: outputTokens,
|
||||
};
|
||||
|
||||
const cost = usageCost(usage, model);
|
||||
const cost = usageCost(usage, model, undefined, pricingTime);
|
||||
this.#aiMetering.utilRecordUsageObject(
|
||||
{
|
||||
[`estimated_${inputKey}`]: inputTokens,
|
||||
@@ -1568,6 +1620,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
usage,
|
||||
model,
|
||||
intendedProvider,
|
||||
pricingTime,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1577,7 +1630,8 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
#injectUsdCents(
|
||||
usage: Record<string, number>,
|
||||
model: IChatModel,
|
||||
usageCosts?: Record<string, number>,
|
||||
usageCosts: Record<string, number> | undefined,
|
||||
pricingTime: Date,
|
||||
): void {
|
||||
if (
|
||||
typeof usage.usd_cents === 'number' &&
|
||||
@@ -1585,7 +1639,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const cost = usageCost(usage, model, usageCosts);
|
||||
const cost = usageCost(usage, model, usageCosts, pricingTime);
|
||||
if (!cost) {
|
||||
(usage as Record<string, number | null>).usd_cents = null;
|
||||
return;
|
||||
@@ -1604,6 +1658,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
model: IChatModel;
|
||||
intendedProvider: string;
|
||||
usageCosts?: Record<string, number>;
|
||||
pricingTime: Date;
|
||||
}) {
|
||||
const {
|
||||
completionId,
|
||||
@@ -1612,9 +1667,10 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
model,
|
||||
intendedProvider,
|
||||
usageCosts,
|
||||
pricingTime,
|
||||
} = params;
|
||||
|
||||
const cost = usageCost(usage, model, usageCosts);
|
||||
const cost = usageCost(usage, model, usageCosts, pricingTime);
|
||||
const { inputKey, outputKey } = costKeys(model);
|
||||
const inputTokens = cost
|
||||
? Number(
|
||||
|
||||
@@ -161,6 +161,33 @@ describe('OpenAICompatProvider', () => {
|
||||
expect('tools' in sent[1]!).toBe(false);
|
||||
});
|
||||
|
||||
it("drops a replayed tool call's Gemini extra_content", async () => {
|
||||
const { provider, sent } = makeProvider({});
|
||||
await complete(provider, {
|
||||
messages: [
|
||||
{ role: 'user', content: 'Weather?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'fc_1',
|
||||
name: 'get_weather',
|
||||
input: {},
|
||||
extra_content: {
|
||||
google: { thought_signature: 'c2lnLTE=' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as ICompleteArguments['messages'],
|
||||
});
|
||||
const messages = sent[0]!.messages as Array<{
|
||||
tool_calls?: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
expect(messages[1]!.tool_calls![0]).not.toHaveProperty('extra_content');
|
||||
});
|
||||
|
||||
it('asks for stream usage only when the vendor reports it in-band', async () => {
|
||||
const stream = { async *[Symbol.asyncIterator]() {} };
|
||||
const reporting = makeProvider({}, stream);
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
IChatProvider,
|
||||
ICompleteArguments,
|
||||
PuterMessage,
|
||||
UsageDetails,
|
||||
} from '../types.js';
|
||||
import {
|
||||
contextLengthRetryParams,
|
||||
@@ -73,6 +74,7 @@ export interface ChatCompletionsClient {
|
||||
export type UsageSource = {
|
||||
usage: CompletionUsage;
|
||||
setUsageCosts: (costs: Record<string, number>) => void;
|
||||
setUsageDetails?: (details: UsageDetails) => void;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
/**
|
||||
@@ -157,6 +159,7 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
async complete(
|
||||
args: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
attemptPricingTime?: Date,
|
||||
): Promise<IChatCompleteResult> {
|
||||
const options = this.#options;
|
||||
const model = resolved ?? (await this.resolveModel(args.model));
|
||||
@@ -188,7 +191,10 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
});
|
||||
}
|
||||
messages = this.prepareMessages(
|
||||
await OpenAIUtil.process_input_messages(messages),
|
||||
await OpenAIUtil.process_input_messages(messages, {
|
||||
preserveToolCallOrder: this.preservesToolCallOrder(model),
|
||||
keepExtraContent: this.keepsToolCallExtraContent(model),
|
||||
}),
|
||||
);
|
||||
|
||||
const tools = args.tools
|
||||
@@ -226,7 +232,7 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
};
|
||||
const params = this.vendorParams(shared, args, model, actor);
|
||||
|
||||
let pricingTime = new Date();
|
||||
let pricingTime = attemptPricingTime ?? new Date();
|
||||
let completion;
|
||||
try {
|
||||
completion = await options.client.chat.completions.create(
|
||||
@@ -242,7 +248,7 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
)
|
||||
: undefined;
|
||||
if (!retryParams) throw e;
|
||||
pricingTime = new Date();
|
||||
pricingTime = attemptPricingTime ?? new Date();
|
||||
completion = await options.client.chat.completions.create(
|
||||
retryParams as never,
|
||||
{ signal },
|
||||
@@ -259,10 +265,11 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
}
|
||||
: {}),
|
||||
usage_calculator: (source: UsageSource) => {
|
||||
const { usage, meterOptions } = this.meteredUsage(
|
||||
const { usage, meterOptions, usageDetails } = this.meteredUsage(
|
||||
source,
|
||||
model,
|
||||
);
|
||||
if (usageDetails) source.setUsageDetails?.(usageDetails);
|
||||
const metered = meterChatUsage(
|
||||
this.#metering,
|
||||
actor,
|
||||
@@ -307,6 +314,16 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
|
||||
// -- Vendor hooks ---------------------------------------------------
|
||||
|
||||
/** Whether a replayed turn's tool calls must keep the model's order. */
|
||||
protected preservesToolCallOrder(_model: IChatModel): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether replayed tool calls keep their vendor `extra_content`. */
|
||||
protected keepsToolCallExtraContent(_model: IChatModel): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Last pass over the messages, after `process_input_messages`. */
|
||||
protected prepareMessages(messages: PuterMessage[]): PuterMessage[] {
|
||||
return messages;
|
||||
@@ -322,11 +339,18 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
return params;
|
||||
}
|
||||
|
||||
/** The usage a completion meters as, and how it's billed. */
|
||||
/**
|
||||
* The usage a completion meters as, and how it's billed. `usageDetails`
|
||||
* replaces the breakdown otherwise derived from `usage`.
|
||||
*/
|
||||
protected meteredUsage(
|
||||
source: UsageSource,
|
||||
_model: IChatModel,
|
||||
): { usage: Record<string, number>; meterOptions?: MeterChatUsageOptions } {
|
||||
): {
|
||||
usage: Record<string, number>;
|
||||
meterOptions?: MeterChatUsageOptions;
|
||||
usageDetails?: UsageDetails;
|
||||
} {
|
||||
return { usage: OpenAIUtil.splitCachedPrompt(source.usage) };
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../../../util/userProvisioning.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { normalize_messages } from '../../utils/Messages.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { FILES_API_BETA } from './fileUpload.js';
|
||||
import { CLAUDE_MODELS } from './models.js';
|
||||
@@ -963,6 +964,63 @@ describe('ClaudeProvider.complete request shape', () => {
|
||||
).toMatchObject({ type: 'thinking', signature: 'sig_1' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['string', '{"city":"Paris"}'],
|
||||
['object', { city: 'Paris' }],
|
||||
])(
|
||||
'drops a Gemini thought signature from a replayed tool call with %s arguments',
|
||||
async (_form, args) => {
|
||||
// A Gemini turn replayed against Claude on fallback keeps only
|
||||
// Anthropic-compatible metadata, including cache_control.
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
|
||||
const messages = normalize_messages([
|
||||
{ role: 'user', content: 'Weather in Paris?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
cache_control: { type: 'ephemeral', ttl: '1h' },
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'fc_1',
|
||||
type: 'function',
|
||||
function: { name: 'get_weather', arguments: args },
|
||||
extra_content: {
|
||||
google: { thought_signature: 'c2lnLTE=' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'fc_1', content: '18C' },
|
||||
]);
|
||||
const before = JSON.parse(JSON.stringify(messages));
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
messages,
|
||||
}),
|
||||
);
|
||||
|
||||
const [sent] = messagesCreateMock.mock.calls[0]!;
|
||||
expect(sent.messages[1].content).toEqual([
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'fc_1',
|
||||
name: 'get_weather',
|
||||
input: { city: 'Paris' },
|
||||
cache_control: { type: 'ephemeral', ttl: '1h' },
|
||||
},
|
||||
]);
|
||||
// The driver hands these same objects to the next fallback route.
|
||||
expect(messages).toEqual(before);
|
||||
expect(messages[1].content[0].extra_content).toEqual({
|
||||
google: { thought_signature: 'c2lnLTE=' },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('survives the same messages array being sent twice', async () => {
|
||||
// This is the fallback hazard the copy-on-write exists for: the driver
|
||||
// reuses one messages array across attempts, so if attempt 1 strips
|
||||
@@ -1667,6 +1725,98 @@ describe('ClaudeProvider.complete streaming', () => {
|
||||
expect(part).toEqual({ puter_path: path });
|
||||
});
|
||||
|
||||
it('answers without waiting for the uploaded files to be deleted', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const { actor, path } = await makeUserWithFile();
|
||||
filesUploadMock.mockResolvedValue({ id: 'file_reply_1' });
|
||||
// A delete that never settles must not hold the reply.
|
||||
filesDeleteMock.mockReturnValue(new Promise(() => {}));
|
||||
messagesCreateMock.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
});
|
||||
|
||||
const result = await withTestActor(
|
||||
() =>
|
||||
provider.complete({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
messages: [
|
||||
{ role: 'user', content: [{ puter_path: path }] },
|
||||
],
|
||||
}),
|
||||
actor,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ finish_reason: 'stop' });
|
||||
expect(filesDeleteMock).toHaveBeenCalledWith('file_reply_1', {
|
||||
betas: [FILES_API_BETA],
|
||||
});
|
||||
});
|
||||
|
||||
it('hands a refused stream to the fallback without waiting for the deletes', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const { actor, path } = await makeUserWithFile();
|
||||
filesUploadMock.mockResolvedValue({ id: 'file_stream_2' });
|
||||
filesDeleteMock.mockReturnValue(new Promise(() => {}));
|
||||
const refused = Object.assign(new Error('Overloaded'), {
|
||||
status: 529,
|
||||
});
|
||||
messagesStreamMock.mockReturnValueOnce({
|
||||
...makeStreamLike([]),
|
||||
withResponse: () => Promise.reject(refused),
|
||||
});
|
||||
|
||||
const part: Record<string, unknown> = { puter_path: path };
|
||||
await expect(
|
||||
withTestActor(
|
||||
() =>
|
||||
provider.complete({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
messages: [{ role: 'user', content: [part] }],
|
||||
stream: true,
|
||||
}),
|
||||
actor,
|
||||
),
|
||||
).rejects.toBe(refused);
|
||||
expect(filesDeleteMock).toHaveBeenCalledWith('file_stream_2', {
|
||||
betas: [FILES_API_BETA],
|
||||
});
|
||||
expect(part).toEqual({ puter_path: path });
|
||||
});
|
||||
|
||||
it('leaves nothing unhandled when deleting an uploaded file fails', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const { actor, path } = await makeUserWithFile();
|
||||
filesUploadMock.mockResolvedValue({ id: 'file_gone_1' });
|
||||
filesDeleteMock.mockRejectedValue(new Error('already gone'));
|
||||
messagesCreateMock.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
});
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
try {
|
||||
await withTestActor(
|
||||
() =>
|
||||
provider.complete({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
messages: [
|
||||
{ role: 'user', content: [{ puter_path: path }] },
|
||||
],
|
||||
}),
|
||||
actor,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
}
|
||||
expect(filesDeleteMock).toHaveBeenCalledWith('file_gone_1', {
|
||||
betas: [FILES_API_BETA],
|
||||
});
|
||||
expect(unhandled).toEqual([]);
|
||||
});
|
||||
|
||||
it('surfaces a failure the event iterator swallowed instead of ending the stream clean', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const dropped = Object.assign(new Error('Overloaded'), {
|
||||
|
||||
@@ -403,7 +403,8 @@ export class ClaudeProvider implements IChatProvider {
|
||||
try {
|
||||
await completion.withResponse();
|
||||
} catch (e) {
|
||||
await cleanupUploads();
|
||||
// Best-effort and never rejects, so the fallback need not wait.
|
||||
void cleanupUploads();
|
||||
restoreUploads();
|
||||
throw e;
|
||||
}
|
||||
@@ -512,7 +513,8 @@ export class ClaudeProvider implements IChatProvider {
|
||||
restoreUploads();
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanupUploads();
|
||||
// Best-effort and never rejects, so the response need not wait.
|
||||
void cleanupUploads();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,7 +933,8 @@ export class ClaudeProvider implements IChatProvider {
|
||||
};
|
||||
});
|
||||
|
||||
// Claude requires tool_use.input to be a dictionary
|
||||
// Claude requires tool_use.input to be a dictionary. Gemini metadata
|
||||
// is not part of Anthropic's tool_use schema.
|
||||
messages = messages.map((message) => {
|
||||
if (!Array.isArray(message.content)) return message;
|
||||
let changed = false;
|
||||
@@ -945,14 +948,14 @@ export class ClaudeProvider implements IChatProvider {
|
||||
} catch {
|
||||
input = {};
|
||||
}
|
||||
changed = true;
|
||||
} else if (input === undefined || input === null) {
|
||||
input = {};
|
||||
changed = true;
|
||||
} else {
|
||||
} else if (!('extra_content' in part)) {
|
||||
return part;
|
||||
}
|
||||
return { ...part, input };
|
||||
changed = true;
|
||||
const { extra_content, ...rest } = part;
|
||||
return { ...rest, input };
|
||||
},
|
||||
);
|
||||
return changed ? { ...message, content } : message;
|
||||
|
||||
@@ -47,6 +47,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { costsForRequest, usageCost } from '../../utils/pricing.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { DEEPSEEK_MODELS } from './models.js';
|
||||
import { DeepSeekProvider } from './DeepSeekProvider.js';
|
||||
@@ -621,6 +622,141 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Peak-only pricing ───────────────────────────────────────────────
|
||||
|
||||
describe('DeepSeekProvider peak-only pricing', () => {
|
||||
// 1M prompt tokens of which 250k are cache hits, plus 100k output.
|
||||
const upstreamUsage = {
|
||||
prompt_tokens: 1_000_000,
|
||||
completion_tokens: 100_000,
|
||||
prompt_tokens_details: { cached_tokens: 250_000 },
|
||||
};
|
||||
const meteredUsage = {
|
||||
prompt_tokens: 750_000,
|
||||
completion_tokens: 100_000,
|
||||
cached_tokens: 250_000,
|
||||
};
|
||||
|
||||
// µ¢ per token equals cents per 1M tokens.
|
||||
const peakCosts = {
|
||||
'deepseek-flash': {
|
||||
prompt_tokens: 750_000 * 30,
|
||||
completion_tokens: 100_000 * 120,
|
||||
cached_tokens: 250_000 * 0.6,
|
||||
},
|
||||
'deepseek-v4-pro': {
|
||||
prompt_tokens: 750_000 * 132,
|
||||
completion_tokens: 100_000 * 396,
|
||||
cached_tokens: 250_000 * 4.4,
|
||||
},
|
||||
};
|
||||
|
||||
// DeepSeek's peak windows are 01:00-04:00 and 06:00-10:00 UTC on
|
||||
// weekdays, excluding Chinese public holidays; every instant still
|
||||
// bills at peak rates.
|
||||
const requestTimes = {
|
||||
'last instant before the 01:00 peak': '2026-10-14T00:59:59.999Z',
|
||||
'01:00 peak start': '2026-10-14T01:00:00.000Z',
|
||||
'last instant of the 01:00-04:00 peak': '2026-10-14T03:59:59.999Z',
|
||||
'04:00 peak end': '2026-10-14T04:00:00.000Z',
|
||||
'last instant before the 06:00 peak': '2026-10-14T05:59:59.999Z',
|
||||
'06:00 peak start': '2026-10-14T06:00:00.000Z',
|
||||
'last instant of the 06:00-10:00 peak': '2026-10-14T09:59:59.999Z',
|
||||
'10:00 peak end': '2026-10-14T10:00:00.000Z',
|
||||
'weekday off-peak between windows': '2026-10-14T05:00:00.000Z',
|
||||
'weekday off-peak evening': '2026-10-14T20:00:00.000Z',
|
||||
'Saturday 02:00': '2026-10-17T02:00:00.000Z',
|
||||
'Sunday 08:00': '2026-10-18T08:00:00.000Z',
|
||||
'Chinese National Day (Thursday) 02:00': '2026-10-01T02:00:00.000Z',
|
||||
'Spring Festival holiday (Monday) 08:00': '2027-02-08T08:00:00.000Z',
|
||||
};
|
||||
|
||||
const cases = Object.keys(peakCosts).flatMap((modelId) =>
|
||||
Object.entries(requestTimes).map(([label, iso]) => ({
|
||||
modelId: modelId as keyof typeof peakCosts,
|
||||
label,
|
||||
iso,
|
||||
})),
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('carries the published peak rates per 1M tokens', () => {
|
||||
const rates = Object.fromEntries(
|
||||
DEEPSEEK_MODELS.map((m) => [m.id, m.costs]),
|
||||
);
|
||||
expect(rates).toEqual({
|
||||
'deepseek-flash': {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30,
|
||||
cached_tokens: 0.6,
|
||||
completion_tokens: 120,
|
||||
},
|
||||
'deepseek-v4-pro': {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 132,
|
||||
cached_tokens: 4.4,
|
||||
completion_tokens: 396,
|
||||
},
|
||||
});
|
||||
for (const m of DEEPSEEK_MODELS) {
|
||||
expect(m.pricingOverrides).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(cases)(
|
||||
'bills $modelId at peak rates for a $label request',
|
||||
async ({ modelId, iso }) => {
|
||||
vi.useFakeTimers({ toFake: ['Date'], now: new Date(iso) });
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: upstreamUsage,
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: modelId,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe(`deepseek:${modelId}`);
|
||||
expect(usage).toEqual(meteredUsage);
|
||||
expect(overrides).toEqual(peakCosts[modelId]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(cases)(
|
||||
'quotes $modelId at peak rates for a $label request',
|
||||
({ modelId, iso }) => {
|
||||
vi.useFakeTimers({ toFake: ['Date'], now: new Date(iso) });
|
||||
const model = DEEPSEEK_MODELS.find((m) => m.id === modelId)!;
|
||||
const expected = peakCosts[modelId];
|
||||
// The credit gate prices the request at the current instant.
|
||||
expect(costsForRequest(model, 1_000_000)).toEqual(model.costs);
|
||||
// The reported usd_cents, from recorded costs or the model's rates.
|
||||
for (const recorded of [expected, undefined]) {
|
||||
expect(usageCost(meteredUsage, model, recorded)).toEqual({
|
||||
inputMicroCents: Math.round(
|
||||
expected.prompt_tokens + expected.cached_tokens,
|
||||
),
|
||||
outputMicroCents: expected.completion_tokens,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Streaming deltas ────────────────────────────────────────────────
|
||||
|
||||
describe('DeepSeekProvider.complete streaming', () => {
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
|
||||
import type { IChatModel } from '../../types.js';
|
||||
|
||||
// Hardcoded from https://api-docs.deepseek.com/quick_start/pricing. DeepSeek
|
||||
// bills half price off-peak; we charge the peak rate since the window isn't
|
||||
// known when a request is priced.
|
||||
// Peak rates from https://api-docs.deepseek.com/quick_start/pricing. Direct
|
||||
// DeepSeek always bills at these rates, including off-peak hours, weekends and
|
||||
// holidays, so keep conditional `pricingOverrides` off these models.
|
||||
export const DEEPSEEK_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'deepseek:deepseek/deepseek-flash',
|
||||
|
||||
+331
-15
@@ -20,11 +20,13 @@
|
||||
/**
|
||||
* Integration test for the Gemini chat provider.
|
||||
*
|
||||
* Hits the real Google Gemini API with `gemini-3.7-flash` (one
|
||||
* of the cheapest variants). Skipped when `PUTER_TEST_AI_GEMINI_API_KEY`
|
||||
* is unset.
|
||||
* Hits the real Google Gemini API with `gemini-3.7-flash` (one of the cheapest
|
||||
* variants). Skipped when `PUTER_TEST_AI_GEMINI_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
import { Writable } from 'node:stream';
|
||||
import axios from 'axios';
|
||||
import { setupPuterTestEnv } from '../../../../testUtil.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
@@ -33,32 +35,346 @@ import {
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import type {
|
||||
ICompleteArguments,
|
||||
IChatMessageResult,
|
||||
UsageDetails,
|
||||
} from '../../types.js';
|
||||
import { normalize_messages } from '../../utils/Messages.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { GeminiChatProvider } from './GeminiChatProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_GEMINI_API_KEY';
|
||||
|
||||
/** The reported output breakdown matches the tokens that were billed. */
|
||||
const expectBilledOutput = (
|
||||
usage: Record<string, number>,
|
||||
details: UsageDetails | undefined,
|
||||
) => {
|
||||
expect(usage.prompt_tokens).toBeGreaterThan(0);
|
||||
expect(usage.completion_tokens).toBeGreaterThan(0);
|
||||
expect(details?.outputTokens).toBe(
|
||||
usage.completion_tokens + usage.thinking_tokens,
|
||||
);
|
||||
expect(details?.reasoningTokens).toBe(usage.thinking_tokens);
|
||||
};
|
||||
|
||||
/** Runs a streamed result through `AIChatStream` and returns its events. */
|
||||
const collectStream = async (result: unknown) => {
|
||||
const lines: string[] = [];
|
||||
const chatStream = new AIChatStream({
|
||||
stream: new Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
lines.push(chunk.toString('utf8'));
|
||||
cb();
|
||||
},
|
||||
}),
|
||||
});
|
||||
await withTestActor(() =>
|
||||
(
|
||||
result as {
|
||||
init_chat_stream: (p: {
|
||||
chatStream: AIChatStream;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
).init_chat_stream({ chatStream }),
|
||||
);
|
||||
const events = lines
|
||||
.join('')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
return { events, chatStream };
|
||||
};
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'GeminiChatProvider (integration)',
|
||||
() => {
|
||||
it('returns a non-empty completion from gemini-3.7-flash', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
|
||||
const provider = new GeminiChatProvider(
|
||||
it(
|
||||
'returns a non-empty completion from gemini-3.7-flash',
|
||||
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const provider = new GeminiChatProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gemini-3.7-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Say hi in one word.' },
|
||||
],
|
||||
max_tokens: 256,
|
||||
}),
|
||||
);
|
||||
|
||||
const text = (result as { message?: { content?: string } })
|
||||
.message?.content;
|
||||
expect(typeof text === 'string' && text.length > 0).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
},
|
||||
);
|
||||
|
||||
// Four requests in all. Assertions never print responses or signatures.
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'GeminiChatProvider native Flash models (integration)',
|
||||
() => {
|
||||
const restored = (model: string, args: Partial<ICompleteArguments>) =>
|
||||
withTestActor(() =>
|
||||
new GeminiChatProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
).complete({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'user', content: 'Say hi in one word.' },
|
||||
],
|
||||
reasoning_effort: 'high',
|
||||
// Thinking counts against the cap; leave room for it.
|
||||
max_tokens: 2048,
|
||||
...args,
|
||||
} as ICompleteArguments),
|
||||
);
|
||||
|
||||
it.each([
|
||||
'google/gemini-3.1-flash-lite',
|
||||
'google/gemini-3-flash-preview',
|
||||
])(
|
||||
'answers %s at high thinking with explicit thought usage',
|
||||
async (model) => {
|
||||
const result = (await restored(
|
||||
model,
|
||||
{},
|
||||
)) as IChatMessageResult;
|
||||
|
||||
const text = result.message.content;
|
||||
expect(typeof text).toBe('string');
|
||||
expect((text as string).trim().length).toBeGreaterThan(0);
|
||||
expect(result.usageDetails?.reasoningTokens).toBeGreaterThan(0);
|
||||
expectBilledOutput(result.usage, result.usageDetails);
|
||||
},
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it.each([
|
||||
'google/gemini-3.1-flash-lite',
|
||||
'google/gemini-3-flash-preview',
|
||||
])(
|
||||
'streams %s with a final explicit thought usage report',
|
||||
async (model) => {
|
||||
const { events, chatStream } = await collectStream(
|
||||
await restored(model, { stream: true }),
|
||||
);
|
||||
const usageLine = events.find((e) => e.type === 'usage');
|
||||
expect(events.some((e) => e.type === 'text')).toBe(true);
|
||||
expect(chatStream.reportedUsage).not.toBeNull();
|
||||
expect(usageLine.usageDetails.reasoningTokens).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expectBilledOutput(usageLine.usage, usageLine.usageDetails);
|
||||
},
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Seven requests in all. Assertions never print signatures or the key.
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'GeminiChatProvider gemini-4-argon (integration)',
|
||||
() => {
|
||||
const provider = () =>
|
||||
new GeminiChatProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gemini-3.7-flash',
|
||||
const argon = (args: Partial<ICompleteArguments>) =>
|
||||
withTestActor(() =>
|
||||
provider().complete({
|
||||
model: 'gemini-4-argon',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Say hi in one word.' },
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Reply with the single word OK.',
|
||||
},
|
||||
],
|
||||
max_tokens: 16,
|
||||
}),
|
||||
// Thinking counts against the cap; leave room for it.
|
||||
max_tokens: 8192,
|
||||
...args,
|
||||
} as ICompleteArguments),
|
||||
);
|
||||
|
||||
const text = (result as { message?: { content?: string } }).message
|
||||
?.content;
|
||||
expect(typeof text === 'string' && text.length > 0).toBe(true);
|
||||
});
|
||||
it.each(['low', 'medium', 'high'] as const)(
|
||||
'answers at thinking level %s with explicit usage',
|
||||
async (effort) => {
|
||||
const result = (await argon({
|
||||
reasoning_effort: effort,
|
||||
})) as IChatMessageResult;
|
||||
expect(typeof result.message.content).toBe('string');
|
||||
expectBilledOutput(result.usage, result.usageDetails);
|
||||
},
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'streams with a final explicit usage report',
|
||||
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { events, chatStream } = await collectStream(
|
||||
await argon({ stream: true, reasoning_effort: 'low' }),
|
||||
);
|
||||
const usageLine = events.find((e) => e.type === 'usage');
|
||||
expect(events.some((e) => e.type === 'text')).toBe(true);
|
||||
expect(chatStream.reportedUsage).not.toBeNull();
|
||||
expectBilledOutput(usageLine.usage, usageLine.usageDetails);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
'reads a small inline image',
|
||||
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
// A 5x5 red dot.
|
||||
const redDot =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==';
|
||||
const result = (await argon({
|
||||
reasoning_effort: 'low',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'What color is this image? Answer with one word.',
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: redDot },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})) as IChatMessageResult;
|
||||
expect(String(result.message.content)).toMatch(/red/i);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
'round-trips a forced tool call with its thought signature',
|
||||
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_weather',
|
||||
description: 'Current weather for a city',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { city: { type: 'string' } },
|
||||
required: ['city'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
const question = {
|
||||
role: 'user',
|
||||
content: 'What is the weather in Paris?',
|
||||
};
|
||||
const first = (await argon({
|
||||
reasoning_effort: 'low',
|
||||
tools,
|
||||
tool_choice: { type: 'tool', name: 'get_weather' },
|
||||
messages: [question],
|
||||
})) as IChatMessageResult;
|
||||
const calls = first.message.tool_calls as Array<{
|
||||
id: string;
|
||||
extra_content?: { google?: { thought_signature?: string } };
|
||||
}>;
|
||||
expect(calls?.length).toBeGreaterThan(0);
|
||||
const signature =
|
||||
calls[0]!.extra_content?.google?.thought_signature;
|
||||
expect(
|
||||
typeof signature === 'string' && signature.length > 0,
|
||||
).toBe(true);
|
||||
|
||||
// Google rejects a follow-up whose call lost its signature.
|
||||
const second = (await argon({
|
||||
reasoning_effort: 'low',
|
||||
tools,
|
||||
messages: normalize_messages([
|
||||
question,
|
||||
first.message,
|
||||
...calls.map((call) => ({
|
||||
role: 'tool',
|
||||
tool_call_id: call.id,
|
||||
content: '18C and sunny',
|
||||
})),
|
||||
]),
|
||||
})) as IChatMessageResult;
|
||||
expect(String(second.message.content).length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'Argon authenticated HTTP (integration)',
|
||||
() => {
|
||||
it(
|
||||
'completes through the public driver route',
|
||||
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const env = await setupPuterTestEnv({
|
||||
providers: {
|
||||
gemini: { apiKey: optionalEnv(ENV_VAR)! },
|
||||
ollama: { enabled: false },
|
||||
},
|
||||
unlimitedMetering: true,
|
||||
extensions: [],
|
||||
});
|
||||
try {
|
||||
const endpoint = new URL('/drivers/call', env.apiOrigin);
|
||||
const host = endpoint.host;
|
||||
endpoint.hostname = '127.0.0.1';
|
||||
const response = await axios.request({
|
||||
url: endpoint.toString(),
|
||||
validateStatus: () => true,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${env.users.user.apiToken}`,
|
||||
Origin: env.apiOrigin,
|
||||
Host: host,
|
||||
},
|
||||
data: {
|
||||
interface: 'puter-chat-completion',
|
||||
method: 'complete',
|
||||
args: {
|
||||
model: 'gemini-4-argon',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Reply with exactly OK.',
|
||||
},
|
||||
],
|
||||
reasoning_effort: 'low',
|
||||
max_tokens: 256,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const body = response.data;
|
||||
expect(JSON.stringify(body)).toContain('OK');
|
||||
} finally {
|
||||
await env.shutdown();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -48,10 +48,12 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { normalize_messages } from '../../utils/Messages.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { GEMINI_MODELS } from './models.js';
|
||||
import { GeminiChatProvider } from './GeminiChatProvider.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { costsForRequest, listedCosts } from '../../utils/pricing.js';
|
||||
|
||||
// ── OpenAI SDK mock ─────────────────────────────────────────────────
|
||||
//
|
||||
@@ -287,6 +289,46 @@ describe('GeminiChatProvider.complete request shape', () => {
|
||||
expect('cache_control' in args.messages[0]).toBe(false);
|
||||
});
|
||||
|
||||
it('replays a tool call with its thought signature', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: normalize_messages([
|
||||
{ role: 'user', content: 'Weather in Paris?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'fc_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_weather',
|
||||
arguments: '{"city":"Paris"}',
|
||||
},
|
||||
extra_content: {
|
||||
google: { thought_signature: 'c2lnLTE=' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'fc_1', content: '18C' },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.messages[1].tool_calls).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'fc_1',
|
||||
extra_content: { google: { thought_signature: 'c2lnLTE=' } },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('only sets stream_options.include_usage when streaming', async () => {
|
||||
const { provider } = makeProvider();
|
||||
|
||||
@@ -409,43 +451,185 @@ describe('GeminiChatProvider model resolution', () => {
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'gemini-3.1-flash-lite',
|
||||
'gemini-3.1-flash-lite-preview',
|
||||
'gemini-3-flash-preview',
|
||||
])('drops the deprecated %s instead of redirecting it', async (model) => {
|
||||
it('drops the deprecated gemini-3.1-flash-lite-preview instead of redirecting it', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const ids = modelLookupNames(await provider.models());
|
||||
expect(ids).not.toContain(model);
|
||||
expect(ids).not.toContain(`google/${model}`);
|
||||
expect(ids).not.toContain('gemini-3.1-flash-lite-preview');
|
||||
expect(ids).not.toContain('google/gemini-3.1-flash-lite-preview');
|
||||
});
|
||||
});
|
||||
|
||||
// -- Scheduled and prompt-size pricing -------------------------------
|
||||
|
||||
describe('GeminiChatProvider pricing tiers', () => {
|
||||
const completion = (usage: Record<string, unknown>) => ({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage,
|
||||
});
|
||||
|
||||
const settle = async (
|
||||
model: string,
|
||||
usage: Record<string, unknown>,
|
||||
pricingTime: Date,
|
||||
) => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(completion(usage));
|
||||
await withTestActor(() =>
|
||||
provider.complete(
|
||||
{ model, messages: [{ role: 'user', content: 'hi' }] },
|
||||
undefined,
|
||||
pricingTime,
|
||||
),
|
||||
);
|
||||
expect(createMock.mock.calls[0]![0].model).toBe(model);
|
||||
return recordSpy.mock.calls[0]![3];
|
||||
};
|
||||
|
||||
// gemini-3.5-flash and gemini-3.7-flash are served by 3.6 and 3.8 Flash.
|
||||
const SCHEDULED = [
|
||||
'gemini-3.5-flash',
|
||||
'gemini-3.6-flash',
|
||||
'gemini-3.7-flash',
|
||||
'gemini-3.8-flash',
|
||||
].flatMap((model) => [
|
||||
[model, '2026-12-31T23:59:59.999Z', 75, 375, 7.5] as const,
|
||||
[model, '2027-01-01T00:00:00.000Z', 150, 750, 15] as const,
|
||||
]);
|
||||
|
||||
it.each([
|
||||
['gemini-3.1-pro-preview-customtools', 200, 1200, 20],
|
||||
['gemini-robotics-er-2-preview', 100, 500, 10],
|
||||
...SCHEDULED,
|
||||
['gemini-robotics-er-2-preview', '2026-12-31T23:59:59.999Z', 100, 500, 10] as const,
|
||||
['gemini-robotics-er-2-preview', '2027-01-01T00:00:00.000Z', 200, 1000, 20] as const,
|
||||
])(
|
||||
'sends %s under its own id and prices it at list rates',
|
||||
async (model, input, output, cached) => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
'settles %s started at %s at its rates in force',
|
||||
async (model, iso, input, output, cached) => {
|
||||
const overrides = await settle(
|
||||
model,
|
||||
{
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 60,
|
||||
completion_tokens_details: { reasoning_tokens: 20 },
|
||||
prompt_tokens_details: { cached_tokens: 400 },
|
||||
},
|
||||
new Date(iso),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe(model);
|
||||
expect(
|
||||
GEMINI_MODELS.find((m) => m.id === model)?.costs,
|
||||
).toMatchObject({
|
||||
prompt_tokens: input,
|
||||
completion_tokens: output,
|
||||
cached_tokens: cached,
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 600 * input,
|
||||
completion_tokens: 40 * output,
|
||||
cached_tokens: 400 * cached,
|
||||
thinking_tokens: 20 * output,
|
||||
grounding_requests: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['gemini-3.6-flash', 'gemini-robotics-er-2-preview'])(
|
||||
'lists %s at launch rates before the cutoff and doubled from it',
|
||||
(id) => {
|
||||
const model = GEMINI_MODELS.find((m) => m.id === id)!;
|
||||
const before = listedCosts(
|
||||
model,
|
||||
new Date('2026-12-31T23:59:59.999Z'),
|
||||
);
|
||||
const after = listedCosts(model, new Date('2027-01-01T00:00:00Z'));
|
||||
for (const key of [
|
||||
'prompt_tokens',
|
||||
'completion_tokens',
|
||||
'thinking_tokens',
|
||||
'cached_tokens',
|
||||
]) {
|
||||
expect(after[key]).toBe(before[key]! * 2);
|
||||
}
|
||||
expect(after.grounding_requests).toBe(1_400_000);
|
||||
},
|
||||
);
|
||||
|
||||
describe.each([
|
||||
'gemini-3.1-pro-preview',
|
||||
'gemini-3.1-pro-preview-customtools',
|
||||
])('%s long-context tier', (model) => {
|
||||
const NOW = new Date('2026-10-14T12:00:00Z');
|
||||
|
||||
it('bills a 200,000-token prompt, cached tokens included, at base rates', async () => {
|
||||
const overrides = await settle(
|
||||
model,
|
||||
{
|
||||
prompt_tokens: 200_000,
|
||||
completion_tokens: 100,
|
||||
completion_tokens_details: { reasoning_tokens: 40 },
|
||||
prompt_tokens_details: { cached_tokens: 150_000 },
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 50_000 * 200,
|
||||
completion_tokens: 60 * 1200,
|
||||
cached_tokens: 150_000 * 20,
|
||||
thinking_tokens: 40 * 1200,
|
||||
grounding_requests: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('bills every token of a longer prompt at the long-context rates once', async () => {
|
||||
const overrides = await settle(
|
||||
model,
|
||||
{
|
||||
prompt_tokens: 200_001,
|
||||
completion_tokens: 100,
|
||||
completion_tokens_details: { reasoning_tokens: 40 },
|
||||
prompt_tokens_details: { cached_tokens: 150_000 },
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 50_001 * 400,
|
||||
completion_tokens: 60 * 1800,
|
||||
cached_tokens: 150_000 * 40,
|
||||
thinking_tokens: 40 * 1800,
|
||||
grounding_requests: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('holds and lists by the same tier the request settles at', () => {
|
||||
const entry = GEMINI_MODELS.find((m) => m.id === model)!;
|
||||
expect(costsForRequest(entry, 200_000, NOW)).toMatchObject({
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 1200,
|
||||
cached_tokens: 20,
|
||||
});
|
||||
expect(costsForRequest(entry, 200_001, NOW)).toMatchObject({
|
||||
prompt_tokens: 400,
|
||||
completion_tokens: 1800,
|
||||
thinking_tokens: 1800,
|
||||
cached_tokens: 40,
|
||||
grounding_requests: 1_400_000,
|
||||
});
|
||||
expect(listedCosts(entry, NOW)).toBe(entry.costs);
|
||||
});
|
||||
});
|
||||
|
||||
it('prices gemini-2.5-pro at its list rates on both sides of 200,000 tokens', () => {
|
||||
const entry = GEMINI_MODELS.find((m) => m.id === 'gemini-2.5-pro')!;
|
||||
const now = new Date('2026-10-14T12:00:00Z');
|
||||
expect(costsForRequest(entry, 200_000, now)).toMatchObject({
|
||||
prompt_tokens: 125,
|
||||
completion_tokens: 1000,
|
||||
thinking_tokens: 1000,
|
||||
cached_tokens: 12.5,
|
||||
});
|
||||
expect(costsForRequest(entry, 200_001, now)).toMatchObject({
|
||||
prompt_tokens: 250,
|
||||
completion_tokens: 1500,
|
||||
thinking_tokens: 1500,
|
||||
cached_tokens: 25,
|
||||
grounding_requests: 3_500_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Non-stream completion ───────────────────────────────────────────
|
||||
|
||||
@@ -17,33 +17,97 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Preamble: Before this we used Gemini's SDK directly and as we found out
|
||||
// its actually kind of terrible. So we use the openai sdk now
|
||||
// Most models go through Google's OpenAI-compatible endpoint with the openai
|
||||
// SDK; only GEMINI_NATIVE_MODELS use the native generateContent API.
|
||||
import {
|
||||
type GenerateContentResponseUsageMetadata,
|
||||
GoogleGenAI,
|
||||
} from '@google/genai';
|
||||
import { OpenAI } from 'openai';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { PuterMessage } from '../../types.js';
|
||||
import type {
|
||||
IChatModel,
|
||||
ICompleteArguments,
|
||||
PuterMessage,
|
||||
UsageDetails,
|
||||
} from '../../types.js';
|
||||
import { toolChoiceToWire } from '../../utils/openaiParams.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import {
|
||||
type ChatCompletionsClient,
|
||||
type ChatProviderConfig,
|
||||
OpenAICompatProvider,
|
||||
type UsageSource,
|
||||
} from '../OpenAICompatProvider.js';
|
||||
import { GEMINI_MODELS } from './models.js';
|
||||
import { usesGoogleGeminiEndpoint } from '../../../util/geminiRest.js';
|
||||
import { geminiNativeClient } from './geminiNative.js';
|
||||
import {
|
||||
GEMINI_MODELS,
|
||||
GEMINI_NATIVE_MODELS,
|
||||
GEMINI_ARGON_NON_STREAM_MAX_TOKENS,
|
||||
} from './models.js';
|
||||
|
||||
type GroundingContent = { grounding_metadata?: unknown };
|
||||
|
||||
/** Unary Argon uses a lower output cap than streaming. */
|
||||
const nativeOutputCap = (
|
||||
args: ICompleteArguments,
|
||||
model: IChatModel,
|
||||
): number | undefined =>
|
||||
model.id === 'gemini-4-argon' && !args.stream
|
||||
? GEMINI_ARGON_NON_STREAM_MAX_TOKENS
|
||||
: undefined;
|
||||
|
||||
const COMPAT_BASE_URL =
|
||||
'https://generativelanguage.googleapis.com/v1beta/openai/';
|
||||
|
||||
/** The catalog without the native models, for a custom `apiBaseUrl`. */
|
||||
const COMPAT_ONLY_MODELS = GEMINI_MODELS.filter(
|
||||
(m) => !GEMINI_NATIVE_MODELS.has(m.id),
|
||||
);
|
||||
|
||||
export class GeminiChatProvider extends OpenAICompatProvider {
|
||||
constructor(config: ChatProviderConfig, meteringService: MeteringService) {
|
||||
const compat = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.apiBaseUrl ?? COMPAT_BASE_URL,
|
||||
...sdkClientOptions(),
|
||||
});
|
||||
// A custom base URL may be a proxy that only speaks the
|
||||
// OpenAI-compatible API, so native models are left out rather than
|
||||
// sent straight to Google.
|
||||
const native = usesGoogleGeminiEndpoint(config.apiBaseUrl)
|
||||
? geminiNativeClient(new GoogleGenAI({ apiKey: config.apiKey }))
|
||||
: undefined;
|
||||
const client: ChatCompletionsClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: (params, options) => {
|
||||
const { model } = params as { model: string };
|
||||
if (!GEMINI_NATIVE_MODELS.has(model)) {
|
||||
return compat.chat.completions.create(
|
||||
params,
|
||||
options,
|
||||
);
|
||||
}
|
||||
if (!native) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Model not found: ${model}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return native.chat.completions.create(params, options);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const models = native ? GEMINI_MODELS : COMPAT_ONLY_MODELS;
|
||||
super(meteringService, {
|
||||
client: new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL:
|
||||
config.apiBaseUrl ??
|
||||
'https://generativelanguage.googleapis.com/v1beta/openai/',
|
||||
...sdkClientOptions(),
|
||||
}),
|
||||
client,
|
||||
defaultModel: 'gemini-2.5-flash',
|
||||
models: () => GEMINI_MODELS,
|
||||
models: () => models,
|
||||
meteringPrefix: 'gemini',
|
||||
maxTokensParam: 'max_completion_tokens',
|
||||
passthrough: ['temperature'],
|
||||
@@ -54,12 +118,77 @@ export class GeminiChatProvider extends OpenAICompatProvider {
|
||||
});
|
||||
}
|
||||
|
||||
/** Sizes the credit hold to the same output cap `vendorParams` sends. */
|
||||
requestPricing(
|
||||
args: ICompleteArguments,
|
||||
model: IChatModel,
|
||||
): { maxTokens?: number } {
|
||||
const maxTokens = nativeOutputCap(args, model);
|
||||
return maxTokens === undefined ? {} : { maxTokens };
|
||||
}
|
||||
|
||||
protected override prepareMessages(messages: PuterMessage[]) {
|
||||
for (const message of messages) delete message.cache_control;
|
||||
return messages;
|
||||
}
|
||||
|
||||
protected override meteredUsage(source: UsageSource) {
|
||||
protected override vendorParams(
|
||||
params: Record<string, unknown>,
|
||||
args: ICompleteArguments,
|
||||
model: IChatModel,
|
||||
) {
|
||||
const native = GEMINI_NATIVE_MODELS.get(model.id);
|
||||
if (!native) return params;
|
||||
// Unset leaves the thinking level to Google's per-model default.
|
||||
const effort = args.reasoning_effort ?? args.reasoning?.effort;
|
||||
if (effort !== undefined && !native.reasoningEfforts.has(effort)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`reasoning_effort must be one of ${[...native.reasoningEfforts].join(', ')} for ${model.id}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const { temperature, ...shared } = params;
|
||||
const hasTools = Array.isArray(params.tools) && params.tools.length > 0;
|
||||
const outputCap = nativeOutputCap(args, model);
|
||||
const requested = params.max_completion_tokens as number | undefined;
|
||||
return {
|
||||
...shared,
|
||||
...(native.temperature && temperature !== undefined
|
||||
? { temperature }
|
||||
: {}),
|
||||
...(outputCap !== undefined
|
||||
? {
|
||||
max_completion_tokens: Math.min(
|
||||
requested ?? outputCap,
|
||||
outputCap,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(effort ? { reasoning_effort: effort } : {}),
|
||||
...(hasTools && args.tool_choice
|
||||
? { tool_choice: toolChoiceToWire(args.tool_choice, 'chat') }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
protected override preservesToolCallOrder(model: IChatModel) {
|
||||
// Gemini expects parallel calls back in order, signature on the first.
|
||||
return GEMINI_NATIVE_MODELS.has(model.id);
|
||||
}
|
||||
|
||||
protected override keepsToolCallExtraContent() {
|
||||
// Carries the thought signature Gemini needs replayed with each call.
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override meteredUsage(source: UsageSource, model: IChatModel) {
|
||||
if (GEMINI_NATIVE_MODELS.has(model.id)) {
|
||||
return nativeMeteredUsage(
|
||||
source.usage as unknown as
|
||||
GenerateContentResponseUsageMetadata | undefined,
|
||||
);
|
||||
}
|
||||
// Non-stream grounding metadata lives in choices[0].message; a stream
|
||||
// hands over what its handler accumulated as `extra_content`.
|
||||
const { usage } = source;
|
||||
@@ -93,3 +222,47 @@ export class GeminiChatProvider extends OpenAICompatProvider {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const count = (value: number | undefined): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
|
||||
/**
|
||||
* Native `usageMetadata` as metered. Candidates and thoughts are separate
|
||||
* output counts; cached tokens are part of the prompt. Metadata with no counts
|
||||
* meters nothing, so the driver estimates the completion instead.
|
||||
*/
|
||||
const nativeMeteredUsage = (
|
||||
metadata: GenerateContentResponseUsageMetadata | undefined,
|
||||
): { usage: Record<string, number>; usageDetails?: UsageDetails } => {
|
||||
const prompt = count(metadata?.promptTokenCount);
|
||||
const candidates = count(metadata?.candidatesTokenCount);
|
||||
const thoughts = count(metadata?.thoughtsTokenCount);
|
||||
const cached = count(metadata?.cachedContentTokenCount) ?? 0;
|
||||
// Server-tool prompt tokens; no server tool is ever enabled here.
|
||||
const toolUse = count(metadata?.toolUsePromptTokenCount) ?? 0;
|
||||
if (
|
||||
prompt === undefined &&
|
||||
candidates === undefined &&
|
||||
thoughts === undefined &&
|
||||
!cached &&
|
||||
!toolUse
|
||||
) {
|
||||
return { usage: {} };
|
||||
}
|
||||
const inputTokens = Math.max(0, (prompt ?? 0) - cached);
|
||||
return {
|
||||
usage: {
|
||||
prompt_tokens: inputTokens,
|
||||
completion_tokens: candidates ?? 0,
|
||||
thinking_tokens: thoughts ?? 0,
|
||||
cached_tokens: cached,
|
||||
...(toolUse ? { tool_use_prompt_tokens: toolUse } : {}),
|
||||
},
|
||||
usageDetails: {
|
||||
inputTokens: inputTokens + toolUse,
|
||||
outputTokens: (candidates ?? 0) + (thoughts ?? 0),
|
||||
reasoningTokens: thoughts ?? 0,
|
||||
...(cached ? { cacheReadTokens: cached } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,517 @@
|
||||
/*
|
||||
* 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 Chat Completions-shaped client over Gemini's native `generateContent`.
|
||||
* Results carry the native `usageMetadata` as `usage`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Content,
|
||||
FunctionCallingConfigMode,
|
||||
GenerateContentConfig,
|
||||
GenerateContentResponse,
|
||||
GoogleGenAI,
|
||||
Part,
|
||||
ThinkingLevel,
|
||||
} from '@google/genai';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import {
|
||||
parseDataUri,
|
||||
unsupportedMediaTextPart,
|
||||
} from '../../utils/mediaParts.js';
|
||||
import { SDK_TIMEOUT_MS } from '../../utils/sdkClient.js';
|
||||
import { promoteStopForToolCalls } from '../../utils/stopReason.js';
|
||||
import { UpstreamDeadlineError } from '../../../util/upstreamErrors.js';
|
||||
import type { ChatCompletionsClient } from '../OpenAICompatProvider.js';
|
||||
|
||||
type ChatContentPart =
|
||||
string | { type?: string; text?: string; image_url?: { url?: string } };
|
||||
|
||||
type ChatContent = string | ChatContentPart[] | null | undefined;
|
||||
|
||||
interface ChatToolCall {
|
||||
id?: string;
|
||||
function: { name: string; arguments: string };
|
||||
extra_content?: { google?: { thought_signature?: string } };
|
||||
}
|
||||
|
||||
/** A message as `process_input_messages` leaves it. */
|
||||
interface ChatMessage {
|
||||
role: 'system' | 'developer' | 'user' | 'assistant' | 'tool';
|
||||
content?: ChatContent;
|
||||
tool_calls?: ChatToolCall[];
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
/** Wire `tool_choice`, as `toolChoiceToWire` emits it for the chat dialect. */
|
||||
type ChatToolChoice =
|
||||
| 'auto'
|
||||
| 'none'
|
||||
| 'required'
|
||||
| { type: 'function'; function: { name: string } };
|
||||
|
||||
/** The request fields `OpenAICompatProvider` and the Gemini hooks send. */
|
||||
interface ChatParams {
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: Array<{
|
||||
type: string;
|
||||
function?: { name: string; description?: string; parameters?: unknown };
|
||||
}>;
|
||||
tool_choice?: ChatToolChoice;
|
||||
max_completion_tokens?: number;
|
||||
temperature?: number;
|
||||
reasoning_effort?: 'minimal' | 'low' | 'medium' | 'high';
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
/** Ids made up for calls Gemini returned without one; never sent back. */
|
||||
const SYNTHETIC_CALL_ID_PREFIX = 'gemini_call_';
|
||||
|
||||
const nativeCallId = (id: string | undefined): { id?: string } =>
|
||||
id && !id.startsWith(SYNTHETIC_CALL_ID_PREFIX) ? { id } : {};
|
||||
|
||||
/**
|
||||
* Tool-call arguments as an object. Replayed calls arrive JSON-encoded twice,
|
||||
* so a string result is parsed once more.
|
||||
*/
|
||||
const parseArguments = (value: string): Record<string, unknown> => {
|
||||
try {
|
||||
let parsed: unknown = JSON.parse(value);
|
||||
if (typeof parsed === 'string') parsed = JSON.parse(parsed);
|
||||
return parsed && typeof parsed === 'object'
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const textOf = (content: ChatContent): string => {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.map((part) =>
|
||||
typeof part === 'string'
|
||||
? part
|
||||
: typeof part.text === 'string'
|
||||
? part.text
|
||||
: '',
|
||||
)
|
||||
.join('');
|
||||
};
|
||||
|
||||
const contentParts = (content: ChatContent): Part[] => {
|
||||
if (typeof content === 'string') return content ? [{ text: content }] : [];
|
||||
if (!Array.isArray(content)) return [];
|
||||
const parts: Part[] = [];
|
||||
for (const part of content) {
|
||||
if (typeof part === 'string') {
|
||||
if (part) parts.push({ text: part });
|
||||
} else if (part.type === 'text') {
|
||||
if (typeof part.text === 'string' && part.text) {
|
||||
parts.push({ text: part.text });
|
||||
}
|
||||
} else if (part.type === 'image_url') {
|
||||
// http(s) images were already inlined as data URLs.
|
||||
const uri = parseDataUri(part.image_url?.url ?? '');
|
||||
parts.push(
|
||||
uri?.base64
|
||||
? { inlineData: { mimeType: uri.mimeType, data: uri.data } }
|
||||
: unsupportedMediaTextPart('image could not be loaded'),
|
||||
);
|
||||
} else {
|
||||
// Audio and file parts have no mapping here; dropping them would
|
||||
// send Google a different prompt than the caller wrote.
|
||||
throw new HttpError(
|
||||
400,
|
||||
`${part.type ?? 'untyped'} content is not supported by this model`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
const toGeminiContents = (
|
||||
messages: ChatMessage[],
|
||||
): { contents: Content[]; systemInstruction?: Content } => {
|
||||
const system: Part[] = [];
|
||||
const contents: Content[] = [];
|
||||
const toolNames = new Map<string, string>();
|
||||
const push = (role: 'user' | 'model', parts: Part[]) => {
|
||||
if (!parts.length) return;
|
||||
const last = contents.at(-1);
|
||||
if (last?.role === role) last.parts!.push(...parts);
|
||||
else contents.push({ role, parts });
|
||||
};
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === 'system' || message.role === 'developer') {
|
||||
system.push(...contentParts(message.content));
|
||||
} else if (message.role === 'assistant') {
|
||||
const parts = contentParts(message.content);
|
||||
for (const call of message.tool_calls ?? []) {
|
||||
if (call.id) toolNames.set(call.id, call.function.name);
|
||||
const signature = call.extra_content?.google?.thought_signature;
|
||||
parts.push({
|
||||
functionCall: {
|
||||
...nativeCallId(call.id),
|
||||
name: call.function.name,
|
||||
args: parseArguments(call.function.arguments),
|
||||
},
|
||||
...(signature ? { thoughtSignature: signature } : {}),
|
||||
});
|
||||
}
|
||||
push('model', parts);
|
||||
} else if (message.role === 'tool') {
|
||||
const name = toolNames.get(message.tool_call_id ?? '');
|
||||
if (!name) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'A tool result does not follow a matching tool call',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
push('user', [
|
||||
{
|
||||
functionResponse: {
|
||||
...nativeCallId(message.tool_call_id),
|
||||
name,
|
||||
response: { result: textOf(message.content) },
|
||||
},
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
push('user', contentParts(message.content));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contents,
|
||||
...(system.length ? { systemInstruction: { parts: system } } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const toolConfigOf = (
|
||||
toolChoice: ChatToolChoice | undefined,
|
||||
): GenerateContentConfig['toolConfig'] => {
|
||||
if (toolChoice === undefined) return undefined;
|
||||
const mode = (
|
||||
toolChoice === 'auto' ? 'AUTO' : toolChoice === 'none' ? 'NONE' : 'ANY'
|
||||
) as FunctionCallingConfigMode;
|
||||
return {
|
||||
functionCallingConfig: {
|
||||
mode,
|
||||
...(typeof toolChoice === 'object'
|
||||
? { allowedFunctionNames: [toolChoice.function.name] }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const toGenerateContentRequest = (
|
||||
params: ChatParams,
|
||||
signal: AbortSignal | undefined,
|
||||
) => {
|
||||
const { contents, systemInstruction } = toGeminiContents(params.messages);
|
||||
// Only caller-defined functions; Google's built-in tools aren't offered.
|
||||
const functions = (params.tools ?? []).flatMap((tool) =>
|
||||
tool.type === 'function' && tool.function ? [tool.function] : [],
|
||||
);
|
||||
const toolConfig = functions.length
|
||||
? toolConfigOf(params.tool_choice)
|
||||
: undefined;
|
||||
const config: GenerateContentConfig = {
|
||||
...(signal ? { abortSignal: signal } : {}),
|
||||
...(systemInstruction ? { systemInstruction } : {}),
|
||||
...(params.max_completion_tokens !== undefined
|
||||
? { maxOutputTokens: params.max_completion_tokens }
|
||||
: {}),
|
||||
// The provider leaves temperature out for models that ignore it.
|
||||
...(params.temperature !== undefined
|
||||
? { temperature: params.temperature }
|
||||
: {}),
|
||||
...(functions.length
|
||||
? {
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: functions.map((fn) => ({
|
||||
name: fn.name,
|
||||
...(fn.description
|
||||
? { description: fn.description }
|
||||
: {}),
|
||||
...(fn.parameters
|
||||
? { parametersJsonSchema: fn.parameters }
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(toolConfig ? { toolConfig } : {}),
|
||||
...(params.reasoning_effort
|
||||
? {
|
||||
thinkingConfig: {
|
||||
thinkingLevel:
|
||||
params.reasoning_effort.toUpperCase() as ThinkingLevel,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return { model: params.model, contents, config };
|
||||
};
|
||||
|
||||
const FILTERED_FINISH_REASONS = new Set([
|
||||
'SAFETY',
|
||||
'RECITATION',
|
||||
'BLOCKLIST',
|
||||
'PROHIBITED_CONTENT',
|
||||
'SPII',
|
||||
'IMAGE_SAFETY',
|
||||
'IMAGE_PROHIBITED_CONTENT',
|
||||
'IMAGE_RECITATION',
|
||||
]);
|
||||
|
||||
/** Native finish reason → `finish_reason`; one with no analog passes through. */
|
||||
const finishReasonOf = (
|
||||
reason: string | undefined,
|
||||
sawToolCall: boolean,
|
||||
): string | undefined => {
|
||||
if (!reason) return undefined;
|
||||
// CONTINUATION is a cut at Google's output limit, and the SDK drops the
|
||||
// token that would resume it, so it reads as any other truncation.
|
||||
if (reason === 'MAX_TOKENS' || reason === 'CONTINUATION') return 'length';
|
||||
if (FILTERED_FINISH_REASONS.has(reason)) return 'content_filter';
|
||||
return promoteStopForToolCalls(
|
||||
reason === 'STOP' ? 'stop' : reason,
|
||||
sawToolCall,
|
||||
'tool_calls',
|
||||
);
|
||||
};
|
||||
|
||||
// The SDK and the API docs spell the "no reason given" value differently.
|
||||
const UNSPECIFIED_BLOCK_REASONS = new Set([
|
||||
'BLOCKED_REASON_UNSPECIFIED',
|
||||
'BLOCK_REASON_UNSPECIFIED',
|
||||
]);
|
||||
|
||||
/** Google refused the prompt itself, so there is no candidate to read. */
|
||||
const isPromptBlocked = (response: GenerateContentResponse): boolean => {
|
||||
const reason = response.promptFeedback?.blockReason;
|
||||
return (
|
||||
!response.candidates?.length &&
|
||||
!!reason &&
|
||||
!UNSPECIFIED_BLOCK_REASONS.has(reason)
|
||||
);
|
||||
};
|
||||
|
||||
/** Answer text and tool calls; thought parts are not output and are skipped. */
|
||||
const readParts = (parts: Part[] | undefined, nextCallIndex: () => number) => {
|
||||
let text = '';
|
||||
const toolCalls: Array<
|
||||
ChatToolCall & { id: string; index: number; type: 'function' }
|
||||
> = [];
|
||||
for (const part of parts ?? []) {
|
||||
if (part.thought) continue;
|
||||
if (typeof part.text === 'string') text += part.text;
|
||||
if (part.functionCall) {
|
||||
toolCalls.push({
|
||||
index: nextCallIndex(),
|
||||
id:
|
||||
part.functionCall.id ||
|
||||
`${SYNTHETIC_CALL_ID_PREFIX}${crypto.randomUUID()}`,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: part.functionCall.name ?? '',
|
||||
arguments: JSON.stringify(part.functionCall.args ?? {}),
|
||||
},
|
||||
...(part.thoughtSignature
|
||||
? {
|
||||
extra_content: {
|
||||
google: {
|
||||
thought_signature: part.thoughtSignature,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { text, toolCalls };
|
||||
};
|
||||
|
||||
const toChatCompletion = (response: GenerateContentResponse) => {
|
||||
if (isPromptBlocked(response)) {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
refusal: null as string | null,
|
||||
},
|
||||
finish_reason: 'content_filter',
|
||||
},
|
||||
],
|
||||
usage: response.usageMetadata,
|
||||
};
|
||||
}
|
||||
const candidate = response.candidates?.[0];
|
||||
let index = 0;
|
||||
const { text, toolCalls } = readParts(
|
||||
candidate?.content?.parts,
|
||||
() => index++,
|
||||
);
|
||||
return {
|
||||
choices: candidate
|
||||
? [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: text || (toolCalls.length ? null : ''),
|
||||
refusal: null as string | null,
|
||||
...(toolCalls.length
|
||||
? {
|
||||
tool_calls: toolCalls.map(
|
||||
({ index: _index, ...call }) => call,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
finish_reason:
|
||||
finishReasonOf(
|
||||
candidate.finishReason,
|
||||
toolCalls.length > 0,
|
||||
) ?? 'stop',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
usage: response.usageMetadata,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Native stream responses → Chat Completions chunks. Text and tool calls go out
|
||||
* as separate chunks, since the stream handler reads one or the other.
|
||||
*/
|
||||
async function* toChatChunks(stream: AsyncIterable<GenerateContentResponse>) {
|
||||
let index = 0;
|
||||
let sawToolCall = false;
|
||||
for await (const response of stream) {
|
||||
if (isPromptBlocked(response)) {
|
||||
yield {
|
||||
choices: [
|
||||
{ index: 0, delta: {}, finish_reason: 'content_filter' },
|
||||
],
|
||||
usage: response.usageMetadata,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const candidate = response.candidates?.[0];
|
||||
const { text, toolCalls } = readParts(
|
||||
candidate?.content?.parts,
|
||||
() => index++,
|
||||
);
|
||||
if (toolCalls.length) sawToolCall = true;
|
||||
const deltas: Record<string, unknown>[] = [
|
||||
...(text ? [{ content: text }] : []),
|
||||
...(toolCalls.length ? [{ tool_calls: toolCalls }] : []),
|
||||
];
|
||||
if (!deltas.length) deltas.push({});
|
||||
const finishReason = finishReasonOf(
|
||||
candidate?.finishReason,
|
||||
sawToolCall,
|
||||
);
|
||||
for (const [i, delta] of deltas.entries()) {
|
||||
const last = i === deltas.length - 1;
|
||||
yield {
|
||||
choices: candidate
|
||||
? [
|
||||
{
|
||||
index: 0,
|
||||
delta,
|
||||
finish_reason: last ? finishReason : undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
usage: response.usageMetadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function* rethrowMapped<T>(
|
||||
stream: AsyncIterable<T>,
|
||||
map: (err: unknown) => unknown,
|
||||
) {
|
||||
try {
|
||||
yield* stream;
|
||||
} catch (err) {
|
||||
throw map(err);
|
||||
}
|
||||
}
|
||||
|
||||
export const geminiNativeClient = (
|
||||
client: GoogleGenAI,
|
||||
timeoutMs = SDK_TIMEOUT_MS,
|
||||
): ChatCompletionsClient => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: async (params: never, { signal }) => {
|
||||
// The SDK only listens for a later abort event.
|
||||
signal?.throwIfAborted();
|
||||
// The SDK aborts without a reason, so its own timeout would
|
||||
// look like a client disconnect. This deadline is told apart.
|
||||
const deadline = AbortSignal.timeout(timeoutMs);
|
||||
const timedOut = (err: unknown) =>
|
||||
deadline.aborted && !signal?.aborted
|
||||
? new UpstreamDeadlineError(
|
||||
'Gemini request timed out',
|
||||
{ cause: err },
|
||||
)
|
||||
: err;
|
||||
const chatParams = params as ChatParams;
|
||||
const request = toGenerateContentRequest(
|
||||
chatParams,
|
||||
signal ? AbortSignal.any([signal, deadline]) : deadline,
|
||||
);
|
||||
try {
|
||||
return chatParams.stream
|
||||
? rethrowMapped(
|
||||
toChatChunks(
|
||||
await client.models.generateContentStream(
|
||||
request,
|
||||
),
|
||||
),
|
||||
timedOut,
|
||||
)
|
||||
: toChatCompletion(
|
||||
await client.models.generateContent(request),
|
||||
);
|
||||
} catch (err) {
|
||||
throw timedOut(err);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -17,10 +17,137 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { IChatModel } from '../../types.js';
|
||||
import type {
|
||||
IChatModel,
|
||||
ModelCost,
|
||||
ModelPricingOverride,
|
||||
ReasoningEffort,
|
||||
} from '../../types.js';
|
||||
|
||||
/** What a natively served model accepts beyond the shared request fields. */
|
||||
export interface GeminiNativeOptions {
|
||||
reasoningEfforts: ReadonlySet<ReasoningEffort>;
|
||||
/** False where Google ignores temperature, so it isn't sent. */
|
||||
temperature: boolean;
|
||||
}
|
||||
|
||||
const FLASH_NATIVE_OPTIONS: GeminiNativeOptions = {
|
||||
reasoningEfforts: new Set(['minimal', 'low', 'medium', 'high']),
|
||||
temperature: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Served natively: the OpenAI-compatible endpoint omits these models' thinking
|
||||
* count from usage.
|
||||
*/
|
||||
export const GEMINI_NATIVE_MODELS: ReadonlyMap<string, GeminiNativeOptions> =
|
||||
new Map([
|
||||
[
|
||||
'gemini-4-argon',
|
||||
{
|
||||
reasoningEfforts: new Set(['low', 'medium', 'high']),
|
||||
temperature: false,
|
||||
},
|
||||
],
|
||||
['gemini-3.1-flash-lite', FLASH_NATIVE_OPTIONS],
|
||||
['gemini-3-flash-preview', FLASH_NATIVE_OPTIONS],
|
||||
]);
|
||||
|
||||
const DOUBLED_RATES = [
|
||||
'prompt_tokens',
|
||||
'completion_tokens',
|
||||
'thinking_tokens',
|
||||
'cached_tokens',
|
||||
] as const;
|
||||
|
||||
/** Launch pricing whose token rates all double to list pricing at `startsAt`. */
|
||||
const listPricingFrom = (
|
||||
startsAt: string,
|
||||
launch: ModelCost,
|
||||
): ModelPricingOverride[] => [
|
||||
{
|
||||
startsAt,
|
||||
costs: Object.fromEntries(
|
||||
DOUBLED_RATES.map((key) => [key, launch[key] * 2]),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Gemini 3.6/3.8 Flash launch pricing.
|
||||
const FLASH_LAUNCH_COSTS: ModelCost = {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 75,
|
||||
completion_tokens: 375,
|
||||
thinking_tokens: 375,
|
||||
cached_tokens: 7.5,
|
||||
// Gemini 3.x grounding is $14 / 1,000 requests
|
||||
grounding_requests: 1_400_000,
|
||||
};
|
||||
const FLASH_LIST_PRICING = listPricingFrom(
|
||||
'2027-01-01T00:00:00Z',
|
||||
FLASH_LAUNCH_COSTS,
|
||||
);
|
||||
|
||||
const ARGON_LAUNCH_COSTS: ModelCost = {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 1000,
|
||||
thinking_tokens: 1000,
|
||||
cached_tokens: 10,
|
||||
};
|
||||
|
||||
const ROBOTICS_LAUNCH_COSTS: ModelCost = {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 500,
|
||||
thinking_tokens: 500,
|
||||
cached_tokens: 10,
|
||||
grounding_requests: 1_400_000,
|
||||
};
|
||||
|
||||
// Every rate rises for the whole request once the prompt, cached tokens
|
||||
// included, exceeds 200,000 tokens.
|
||||
const PRO_LONG_CONTEXT_PRICING: ModelPricingOverride[] = [
|
||||
{
|
||||
minPromptTokens: 200_000,
|
||||
costs: {
|
||||
prompt_tokens: 400,
|
||||
completion_tokens: 1800,
|
||||
thinking_tokens: 1800,
|
||||
cached_tokens: 40,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Google's unary model metadata reports this cap; streaming keeps the catalog cap.
|
||||
export const GEMINI_ARGON_NON_STREAM_MAX_TOKENS = 32_768;
|
||||
|
||||
// Hardcoded from https://models.dev/api.json
|
||||
export const GEMINI_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'google:google/gemini-4-argon',
|
||||
id: 'gemini-4-argon',
|
||||
// The native adapter forwards only text and image parts.
|
||||
modalities: {
|
||||
input: ['text', 'image'],
|
||||
output: ['text'],
|
||||
},
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
release_date: '2026-10-13',
|
||||
name: 'Gemini 4 Argon',
|
||||
aliases: ['google/gemini-4-argon'],
|
||||
// One window shared by input, thinking and output.
|
||||
context: 1_048_576,
|
||||
max_tokens: 65_536,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: ARGON_LAUNCH_COSTS,
|
||||
pricingOverrides: listPricingFrom(
|
||||
'2026-12-01T00:00:00Z',
|
||||
ARGON_LAUNCH_COSTS,
|
||||
),
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-3.5-flash',
|
||||
id: 'gemini-3.5-flash',
|
||||
@@ -46,15 +173,9 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 150,
|
||||
completion_tokens: 900,
|
||||
thinking_tokens: 900,
|
||||
cached_tokens: 15,
|
||||
// Gemini 3.x grounding is $14 / 1,000 requests
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
// Google serves this endpoint with gemini-3.6-flash and bills its rates.
|
||||
costs: FLASH_LAUNCH_COSTS,
|
||||
pricingOverrides: FLASH_LIST_PRICING,
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-3.5-flash-lite',
|
||||
@@ -107,14 +228,8 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 150,
|
||||
completion_tokens: 750,
|
||||
thinking_tokens: 750,
|
||||
cached_tokens: 15,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
costs: FLASH_LAUNCH_COSTS,
|
||||
pricingOverrides: FLASH_LIST_PRICING,
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-3.7-flash',
|
||||
@@ -134,14 +249,9 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 75,
|
||||
completion_tokens: 375,
|
||||
thinking_tokens: 375,
|
||||
cached_tokens: 7.5,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
// Google serves this endpoint with gemini-3.8-flash and bills its rates.
|
||||
costs: FLASH_LAUNCH_COSTS,
|
||||
pricingOverrides: FLASH_LIST_PRICING,
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-3.8-flash',
|
||||
@@ -160,14 +270,60 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
// Launch pricing, listed as holding through 2026-12-31 and doubling
|
||||
// on 2027-01-01.
|
||||
costs: FLASH_LAUNCH_COSTS,
|
||||
pricingOverrides: FLASH_LIST_PRICING,
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-3.1-flash-lite',
|
||||
id: 'gemini-3.1-flash-lite',
|
||||
// The native driver has no audio or PDF part to forward.
|
||||
modalities: {
|
||||
input: ['text', 'image'],
|
||||
output: ['text'],
|
||||
},
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
release_date: '2026-05-07',
|
||||
name: 'Gemini 3.1 Flash-Lite',
|
||||
aliases: ['google/gemini-3.1-flash-lite'],
|
||||
context: 1_048_576,
|
||||
max_tokens: 65_536,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 75,
|
||||
completion_tokens: 375,
|
||||
thinking_tokens: 375,
|
||||
cached_tokens: 7.5,
|
||||
prompt_tokens: 25,
|
||||
completion_tokens: 150,
|
||||
thinking_tokens: 150,
|
||||
cached_tokens: 2.5,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-3-flash-preview',
|
||||
id: 'gemini-3-flash-preview',
|
||||
// The native driver has no audio or PDF part to forward.
|
||||
modalities: {
|
||||
input: ['text', 'image'],
|
||||
output: ['text'],
|
||||
},
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
release_date: '2025-12-17',
|
||||
name: 'Gemini 3 Flash Preview',
|
||||
aliases: ['google/gemini-3-flash-preview'],
|
||||
context: 1_048_576,
|
||||
max_tokens: 65_536,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 300,
|
||||
thinking_tokens: 300,
|
||||
cached_tokens: 5,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
},
|
||||
@@ -251,10 +407,21 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 125,
|
||||
completion_tokens: 1000,
|
||||
thinking_tokens: 1000,
|
||||
cached_tokens: 31,
|
||||
cached_tokens: 12.5,
|
||||
// Gemini 2.x grounding is $35 / 1,000 requests
|
||||
grounding_requests: 3_500_000,
|
||||
},
|
||||
pricingOverrides: [
|
||||
{
|
||||
minPromptTokens: 200_000,
|
||||
costs: {
|
||||
prompt_tokens: 250,
|
||||
completion_tokens: 1500,
|
||||
thinking_tokens: 1500,
|
||||
cached_tokens: 25,
|
||||
},
|
||||
},
|
||||
],
|
||||
max_tokens: 65_536,
|
||||
},
|
||||
{
|
||||
@@ -289,6 +456,7 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
cached_tokens: 20,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
pricingOverrides: PRO_LONG_CONTEXT_PRICING,
|
||||
max_tokens: 65536,
|
||||
},
|
||||
{
|
||||
@@ -318,6 +486,7 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
cached_tokens: 20,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
pricingOverrides: PRO_LONG_CONTEXT_PRICING,
|
||||
max_tokens: 65536,
|
||||
},
|
||||
{
|
||||
@@ -338,16 +507,11 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
// Launch pricing, listed as holding through 2026-12-31 and doubling
|
||||
// on 2027-01-01.
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 500,
|
||||
thinking_tokens: 500,
|
||||
cached_tokens: 10,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
costs: ROBOTICS_LAUNCH_COSTS,
|
||||
pricingOverrides: listPricingFrom(
|
||||
'2027-01-01T00:00:00Z',
|
||||
ROBOTICS_LAUNCH_COSTS,
|
||||
),
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemma-4-31b-it',
|
||||
|
||||
@@ -77,13 +77,17 @@ export class MetaProvider extends OpenAICompatProvider {
|
||||
});
|
||||
}
|
||||
|
||||
override async complete(args: ICompleteArguments, resolved?: IChatModel) {
|
||||
override async complete(
|
||||
args: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
pricingTime?: Date,
|
||||
) {
|
||||
if (!Array.isArray(args.messages)) {
|
||||
throw new HttpError(400, '`messages` must be an array', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return super.complete(args, resolved);
|
||||
return super.complete(args, resolved, pricingTime);
|
||||
}
|
||||
|
||||
protected override vendorParams(
|
||||
|
||||
@@ -158,7 +158,11 @@ export class NeuralwattProvider extends OpenAICompatProvider {
|
||||
},
|
||||
});
|
||||
|
||||
override async complete(args: ICompleteArguments, resolved?: IChatModel) {
|
||||
override async complete(
|
||||
args: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
pricingTime?: Date,
|
||||
) {
|
||||
const model = resolved ?? (await this.resolveModel(args.model));
|
||||
if (
|
||||
messagesHaveImageContent(args.messages ?? []) &&
|
||||
@@ -171,7 +175,7 @@ export class NeuralwattProvider extends OpenAICompatProvider {
|
||||
);
|
||||
}
|
||||
this.#accountingMethod = await this.getAccountingMethod();
|
||||
return super.complete(args, model);
|
||||
return super.complete(args, model, pricingTime);
|
||||
}
|
||||
|
||||
protected override vendorParams(
|
||||
|
||||
@@ -143,6 +143,23 @@ const SAMPLE_API_MODELS = [
|
||||
top_provider: { max_completion_tokens: 32768 },
|
||||
expiration_date: '2026-10-09',
|
||||
},
|
||||
{
|
||||
// Batch variants aren't served on chat/completions — filtered out.
|
||||
id: 'openai/gpt-6-luna:batch',
|
||||
name: 'GPT-6 Luna (batch)',
|
||||
created: 1714564800,
|
||||
context_length: 128000,
|
||||
pricing: { prompt: 0.000005, completion: 0.000015 },
|
||||
top_provider: { max_completion_tokens: 16000 },
|
||||
},
|
||||
{
|
||||
id: 'openai/gpt-6-luna:free',
|
||||
name: 'GPT-6 Luna (free)',
|
||||
created: 1714564800,
|
||||
context_length: 128000,
|
||||
pricing: { prompt: 0, completion: 0 },
|
||||
top_provider: { max_completion_tokens: 16000 },
|
||||
},
|
||||
{
|
||||
// 'openrouter/auto' is filtered out — disallowed.
|
||||
id: 'openrouter/auto',
|
||||
@@ -268,6 +285,18 @@ describe('OpenRouterProvider model catalog', () => {
|
||||
expect(ids).not.toContain('qwen/qwen3-max');
|
||||
});
|
||||
|
||||
it('filters out batch variants and keeps their base model', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const ids = modelLookupNames(await provider.models());
|
||||
expect(ids).not.toContain('openrouter:openai/gpt-6-luna:batch');
|
||||
expect(ids).not.toContain('openai/gpt-6-luna:batch');
|
||||
expect(ids).not.toContain('gpt-6-luna:batch');
|
||||
expect(ids).toContain('openrouter:openai/gpt-6-luna');
|
||||
expect(ids).toContain('gpt-6-luna');
|
||||
expect(ids).toContain('openrouter:openai/gpt-6-luna:free');
|
||||
expect(ids).toContain('gpt-6-luna:free');
|
||||
});
|
||||
|
||||
it('coerces string prices and normalizes conditional tiers', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const models = await provider.models();
|
||||
@@ -610,6 +639,126 @@ describe('OpenRouterProvider.complete request shape', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenRouterProvider 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 apiError = (status: number, body: Record<string, unknown>) =>
|
||||
Object.assign(new Error(`${status} ${String(body.message)}`), {
|
||||
status,
|
||||
error: body,
|
||||
code: body.code,
|
||||
});
|
||||
|
||||
const complete = () =>
|
||||
withTestActor(() =>
|
||||
makeProvider().provider.complete({
|
||||
model: 'openrouter:openai/gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const expectModelUnavailable = async () => {
|
||||
const err = await complete().catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({
|
||||
name: 'UpstreamResponseError',
|
||||
status: 403,
|
||||
code: 'model_not_found',
|
||||
provider: 'openrouter',
|
||||
message: 'Model not available: openrouter:openai/gpt-6-luna',
|
||||
});
|
||||
expect(JSON.stringify(err)).not.toMatch(/policy|http|request id/i);
|
||||
expect((err as Error).message).not.toMatch(/policy|http/i);
|
||||
};
|
||||
|
||||
it('reports a numeric 403 policy block as the model being unavailable', async () => {
|
||||
createMock.mockRejectedValueOnce(
|
||||
apiError(403, { code: 403, message: POLICY_MESSAGE }),
|
||||
);
|
||||
await expectModelUnavailable();
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the in-band 403 policy block of a 200 with no choices the same way', async () => {
|
||||
createMock.mockResolvedValueOnce({
|
||||
error: { code: 403, message: POLICY_MESSAGE },
|
||||
});
|
||||
await expectModelUnavailable();
|
||||
expect(recordSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a generic 403', apiError(403, { code: 403, message: 'Forbidden' })],
|
||||
[
|
||||
'a key limit 403',
|
||||
apiError(403, {
|
||||
code: 403,
|
||||
message: 'Key limit exceeded (daily limit).',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'a moderation 403',
|
||||
apiError(403, {
|
||||
code: 403,
|
||||
message:
|
||||
'Your chosen model requires moderation and your input was flagged',
|
||||
metadata: { reasons: ['violence'] },
|
||||
}),
|
||||
],
|
||||
[
|
||||
'a similar 403 wording',
|
||||
apiError(403, {
|
||||
code: 403,
|
||||
message: 'Policy Violation: this key has been blocked.',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'the policy wording without its numeric code',
|
||||
apiError(403, { message: POLICY_MESSAGE }),
|
||||
],
|
||||
[
|
||||
'the policy wording on a 401',
|
||||
apiError(401, { code: 401, message: POLICY_MESSAGE }),
|
||||
],
|
||||
[
|
||||
'the policy wording on a 503',
|
||||
apiError(503, { code: 403, message: POLICY_MESSAGE }),
|
||||
],
|
||||
[
|
||||
'a 402 credit exhaustion',
|
||||
apiError(402, { code: 402, message: 'Insufficient credits' }),
|
||||
],
|
||||
])('rethrows %s unchanged', async (_label, thrown) => {
|
||||
createMock.mockRejectedValueOnce(thrown);
|
||||
await expect(complete()).rejects.toBe(thrown);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no error body', {}, 'no completion was returned'],
|
||||
[
|
||||
'an in-band error with another code',
|
||||
{ error: { code: 401, message: POLICY_MESSAGE } },
|
||||
POLICY_MESSAGE,
|
||||
],
|
||||
[
|
||||
'an in-band error with other wording',
|
||||
{ error: { code: 403, message: 'Provider returned error' } },
|
||||
'Provider returned error',
|
||||
],
|
||||
])(
|
||||
'keeps a no-choice completion with %s as bad_response',
|
||||
async (_label, completion, message) => {
|
||||
createMock.mockResolvedValueOnce(completion);
|
||||
await expect(complete()).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_response',
|
||||
message,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Non-stream completion: cost calculator branches ─────────────────
|
||||
|
||||
describe('OpenRouterProvider.complete non-stream output', () => {
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
|
||||
import axios from 'axios';
|
||||
import { OpenAI } from 'openai';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { HttpError, isHttpError } from '../../../../core/http/HttpError.js';
|
||||
import { UpstreamResponseError } from '../../../util/upstreamErrors.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { IChatModel, ICompleteArguments } from '../../types.js';
|
||||
import { cachedRemoteCatalog } from '../../utils/cachedRemoteCatalog.js';
|
||||
@@ -37,6 +38,29 @@ type OpenrouterUsage = OpenAI.Completions.CompletionUsage & {
|
||||
cost?: number;
|
||||
};
|
||||
|
||||
const POLICY_BLOCK_MESSAGE =
|
||||
'Policy Violation: this user has been blocked for a previous policy violation.';
|
||||
|
||||
const upstreamErrorBody = (err: unknown): unknown => {
|
||||
if (isHttpError(err)) {
|
||||
return err.legacyCode === 'bad_response' ? err.cause : undefined;
|
||||
}
|
||||
const e = err as { status?: unknown; error?: unknown } | undefined;
|
||||
return e?.status === 403 ? e.error : undefined;
|
||||
};
|
||||
|
||||
const isPolicyBlock = (err: unknown): boolean => {
|
||||
const { code, message } = (upstreamErrorBody(err) ?? {}) as {
|
||||
code?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
return (
|
||||
code === 403 &&
|
||||
typeof message === 'string' &&
|
||||
message.startsWith(POLICY_BLOCK_MESSAGE)
|
||||
);
|
||||
};
|
||||
|
||||
export class OpenRouterProvider extends OpenAICompatProvider {
|
||||
#apiBaseUrl: string;
|
||||
|
||||
@@ -63,7 +87,11 @@ export class OpenRouterProvider extends OpenAICompatProvider {
|
||||
this.#apiBaseUrl = apiBaseUrl;
|
||||
}
|
||||
|
||||
override async complete(args: ICompleteArguments, resolved?: IChatModel) {
|
||||
override async complete(
|
||||
args: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
pricingTime?: Date,
|
||||
) {
|
||||
if (args.model === 'openrouter/auto') {
|
||||
throw new HttpError(
|
||||
400,
|
||||
@@ -78,7 +106,17 @@ export class OpenRouterProvider extends OpenAICompatProvider {
|
||||
},
|
||||
);
|
||||
}
|
||||
return super.complete(args, resolved);
|
||||
try {
|
||||
return await super.complete(args, resolved, pricingTime);
|
||||
} catch (e) {
|
||||
if (!isPolicyBlock(e)) throw e;
|
||||
throw new UpstreamResponseError(
|
||||
`Model not available: ${args.model}`,
|
||||
403,
|
||||
'openrouter',
|
||||
'model_not_found',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
override async models(): Promise<IChatModel[]> {
|
||||
@@ -105,7 +143,8 @@ export class OpenRouterProvider extends OpenAICompatProvider {
|
||||
for (const model of models) {
|
||||
if (
|
||||
typeof model.id !== 'string' ||
|
||||
model.id.includes('openrouter/auto')
|
||||
model.id.includes('openrouter/auto') ||
|
||||
model.id.endsWith(':batch')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface ModelPricingOverride {
|
||||
utcStart?: number;
|
||||
utcEnd?: number;
|
||||
utcDays?: number[];
|
||||
/** ISO instant from which this override applies, e.g. a list-price change. */
|
||||
startsAt?: string;
|
||||
costs: ModelCost;
|
||||
}
|
||||
|
||||
@@ -306,10 +308,13 @@ export interface IChatProvider {
|
||||
/**
|
||||
* `model` is this provider's own catalog entry for `arg.model`, as the
|
||||
* driver resolved it; a provider resolves `arg.model` itself without it.
|
||||
* `pricingTime` is the instant the driver's credit gate priced this attempt
|
||||
* at; usage metered for the attempt is priced at the same instant.
|
||||
*/
|
||||
complete(
|
||||
arg: ICompleteArguments,
|
||||
model?: IChatModel,
|
||||
pricingTime?: Date,
|
||||
): Promise<IChatCompleteResult>;
|
||||
checkModeration(
|
||||
text: string,
|
||||
@@ -329,12 +334,19 @@ export interface IChatProvider {
|
||||
/**
|
||||
* Request-specific pricing the credit gate must reserve for (web search,
|
||||
* fast mode, the advisor tool), in µ¢, before the AI cost factor.
|
||||
* `maxTokens` is an output cap the provider holds this request to; the gate
|
||||
* caps `max_tokens` and sizes the hold to it as well.
|
||||
*/
|
||||
requestPricing?(
|
||||
args: ICompleteArguments,
|
||||
model: IChatModel,
|
||||
est: { promptTokenEstimate: number },
|
||||
): { inputKey?: string; outputKey?: string; extraCost?: number };
|
||||
): {
|
||||
inputKey?: string;
|
||||
outputKey?: string;
|
||||
extraCost?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
/**
|
||||
* Exact prompt token count, when the vendor offers one (e.g. Claude's
|
||||
* `count_tokens`).
|
||||
|
||||
@@ -90,6 +90,11 @@ export const normalize_single_message = (message, params = {}) => {
|
||||
id: tool_call.id,
|
||||
name: tool_call.function.name,
|
||||
input: tool_call.function.arguments,
|
||||
// Gemini's thought signature rides here and must be
|
||||
// replayed with the call.
|
||||
...(tool_call.extra_content
|
||||
? { extra_content: tool_call.extra_content }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
delete message.tool_calls;
|
||||
|
||||
@@ -237,6 +237,23 @@ describe('normalize_single_message tool_calls', () => {
|
||||
{ type: 'tool_use', id: 'c1', name: 'lookup', input: '{}' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a tool_call extra_content such as a Gemini thought signature', () => {
|
||||
const extra = { google: { thought_signature: 'c2lnbmF0dXJl' } };
|
||||
const result = normalize_messages([
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'c1',
|
||||
function: { name: 'lookup', arguments: '{}' },
|
||||
extra_content: extra,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
expect(result[0].content[0].extra_content).toEqual(extra);
|
||||
});
|
||||
});
|
||||
|
||||
// ── normalize_messages ──────────────────────────────────────────────
|
||||
|
||||
@@ -151,19 +151,20 @@ export const toOpenAIChatMessages = (
|
||||
/**
|
||||
* Moves a message's `tool_use` blocks into OpenAI `tool_calls`, in place. True
|
||||
* when this call created `tool_calls`. `canonicalId` keeps the Responses item
|
||||
* id a replayed call carries.
|
||||
* id a replayed call carries. `preserveOrder` appends the hoisted calls in
|
||||
* content order instead of reversed. `extraContent` keeps Gemini's
|
||||
* `extra_content`, which other vendors may reject as an unknown field.
|
||||
*/
|
||||
const hoistToolUseBlocks = (msg, { canonicalId = false } = {}) => {
|
||||
const hoistToolUseBlocks = (
|
||||
msg,
|
||||
{ canonicalId = false, preserveOrder = false, extraContent = false } = {},
|
||||
) => {
|
||||
const content = msg.content;
|
||||
let hoisted = false;
|
||||
const calls = [];
|
||||
for (let i = content.length - 1; i >= 0; i--) {
|
||||
const block = content[i];
|
||||
if (block.type !== 'tool_use') continue;
|
||||
if (!msg.tool_calls) {
|
||||
msg.tool_calls = [];
|
||||
hoisted = true;
|
||||
}
|
||||
msg.tool_calls.push({
|
||||
calls.push({
|
||||
id: block.id,
|
||||
...(canonicalId ? { canonical_id: block.canonical_id } : {}),
|
||||
type: 'function',
|
||||
@@ -171,12 +172,16 @@ const hoistToolUseBlocks = (msg, { canonicalId = false } = {}) => {
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.input),
|
||||
},
|
||||
...(block.extra_content
|
||||
...(extraContent && block.extra_content
|
||||
? { extra_content: block.extra_content }
|
||||
: {}),
|
||||
});
|
||||
content.splice(i, 1);
|
||||
}
|
||||
if (!calls.length) return false;
|
||||
const hoisted = !msg.tool_calls;
|
||||
msg.tool_calls ??= [];
|
||||
msg.tool_calls.push(...(preserveOrder ? calls.reverse() : calls));
|
||||
return hoisted;
|
||||
};
|
||||
|
||||
@@ -185,9 +190,15 @@ const hoistToolUseBlocks = (msg, { canonicalId = false } = {}) => {
|
||||
* make changes in-place.
|
||||
*
|
||||
* @param {Message[]} messages - Array of normalized messages
|
||||
* @param {{ preserveToolCallOrder?: boolean; keepExtraContent?: boolean }} [opts]
|
||||
* `preserveToolCallOrder` keeps a turn's tool calls in the order the model made
|
||||
* them. `keepExtraContent` keeps each tool call's `extra_content`.
|
||||
* @returns {Message[]} - Array of messages in OpenAI format
|
||||
*/
|
||||
export const process_input_messages = async (messages) => {
|
||||
export const process_input_messages = async (
|
||||
messages,
|
||||
{ preserveToolCallOrder = false, keepExtraContent = false } = {},
|
||||
) => {
|
||||
for (const msg of messages) {
|
||||
if (!msg.content) continue;
|
||||
if (typeof msg.content !== 'object') continue;
|
||||
@@ -203,7 +214,13 @@ export const process_input_messages = async (messages) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (hoistToolUseBlocks(msg)) msg.content = null;
|
||||
if (
|
||||
hoistToolUseBlocks(msg, {
|
||||
preserveOrder: preserveToolCallOrder,
|
||||
extraContent: keepExtraContent,
|
||||
})
|
||||
)
|
||||
msg.content = null;
|
||||
|
||||
// coerce tool results
|
||||
// (we assume multiple tool results were already split into separate messages)
|
||||
@@ -414,9 +431,6 @@ export const process_input_messages_responses_api = async (messages) => {
|
||||
id: toolCall.canonical_id,
|
||||
name: toolCall.function.name,
|
||||
arguments: toolCall.function.arguments,
|
||||
...(toolCall.extra_content
|
||||
? { extra_content: toolCall.extra_content }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -648,11 +662,15 @@ export const create_chat_stream_handler =
|
||||
// the upstream had already produced *and* skipped its billing. Leave
|
||||
// the usage undefined instead; the driver charges an estimate for a
|
||||
// stream that produced output nobody reported.
|
||||
let usageDetails;
|
||||
const usage = last_usage
|
||||
? usage_calculator({
|
||||
usage: last_usage,
|
||||
extra_content: last_extra_content,
|
||||
setUsageCosts: (c) => chatStream.setUsageCosts(c),
|
||||
setUsageDetails: (d) => {
|
||||
usageDetails = d;
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
// The calculator just metered. Reported here, not only via `end`:
|
||||
@@ -665,7 +683,7 @@ export const create_chat_stream_handler =
|
||||
} else if (opened_tool_blocks.length > 0) {
|
||||
chatStream.setStop({ reason: 'tool_use' });
|
||||
}
|
||||
const usageDetails = usageDetailsFromTrackedUsage(usage);
|
||||
usageDetails ??= usageDetailsFromTrackedUsage(usage);
|
||||
if (usageDetails) chatStream.setUsageDetails(usageDetails);
|
||||
|
||||
if (mode === 'text') textblock.end();
|
||||
@@ -869,6 +887,9 @@ export const handle_completion_output = async (
|
||||
* usage_calculator: (args: {
|
||||
* usage: import('openai/resources/completions.mjs').CompletionUsage;
|
||||
* setUsageCosts: (costs: Record<string, number>) => void;
|
||||
* setUsageDetails: (
|
||||
* details: import('../types.js').UsageDetails,
|
||||
* ) => void;
|
||||
* }) => unknown;
|
||||
* }}
|
||||
*/
|
||||
@@ -907,7 +928,7 @@ export const handle_completion_output = async (
|
||||
typeof upstreamMessage === 'string' && upstreamMessage
|
||||
? upstreamMessage
|
||||
: 'no completion was returned',
|
||||
{ legacyCode: 'bad_response' },
|
||||
{ legacyCode: 'bad_response', cause: completion?.error },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -924,12 +945,15 @@ export const handle_completion_output = async (
|
||||
setUsageCosts: (c) => {
|
||||
ret.usageCosts = c;
|
||||
},
|
||||
setUsageDetails: (d) => {
|
||||
ret.usageDetails = d;
|
||||
},
|
||||
})
|
||||
: {
|
||||
input_tokens: completion_usage.prompt_tokens,
|
||||
output_tokens: completion_usage.completion_tokens,
|
||||
};
|
||||
if (usage_calculator) {
|
||||
if (usage_calculator && ret.usageDetails === undefined) {
|
||||
const usageDetails = usageDetailsFromTrackedUsage(ret.usage);
|
||||
if (usageDetails) ret.usageDetails = usageDetails;
|
||||
}
|
||||
|
||||
@@ -331,8 +331,8 @@ describe('process_input_messages', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves extra_content on hoisted tool calls', async () => {
|
||||
const messages: Array<Record<string, unknown>> = [
|
||||
describe('tool call extra_content', () => {
|
||||
const turn = (): Array<Record<string, unknown>> => [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
@@ -346,12 +346,73 @@ describe('process_input_messages', () => {
|
||||
],
|
||||
},
|
||||
];
|
||||
const firstCall = (out: unknown[]) =>
|
||||
(
|
||||
(out[0] as Record<string, unknown>).tool_calls as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
)[0]!;
|
||||
|
||||
const [out] = (await process_input_messages(messages)) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const calls = out!.tool_calls as Array<Record<string, unknown>>;
|
||||
expect(calls[0]?.extra_content).toEqual({ hint: 'metadata' });
|
||||
it('drops it by default', async () => {
|
||||
const out = await process_input_messages(turn());
|
||||
expect(firstCall(out)).not.toHaveProperty('extra_content');
|
||||
});
|
||||
|
||||
it('keeps it when asked', async () => {
|
||||
const out = await process_input_messages(turn(), {
|
||||
keepExtraContent: true,
|
||||
});
|
||||
expect(firstCall(out).extra_content).toEqual({ hint: 'metadata' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parallel tool call order', () => {
|
||||
const parallelTurn = (): Array<Record<string, unknown>> => [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'a', name: 'first', input: {} },
|
||||
{ type: 'tool_use', id: 'b', name: 'second', input: {} },
|
||||
],
|
||||
},
|
||||
];
|
||||
const callIds = (out: unknown[]) =>
|
||||
(
|
||||
(out[0] as Record<string, unknown>).tool_calls as Array<{
|
||||
id: string;
|
||||
}>
|
||||
).map((call) => call.id);
|
||||
|
||||
it('keeps the existing reversed order by default', async () => {
|
||||
expect(callIds(await process_input_messages(parallelTurn()))).toEqual(
|
||||
['b', 'a'],
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps content order when asked to', async () => {
|
||||
expect(
|
||||
callIds(
|
||||
await process_input_messages(parallelTurn(), {
|
||||
preserveToolCallOrder: true,
|
||||
}),
|
||||
),
|
||||
).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('appends after tool_calls the message already had, without reordering them', async () => {
|
||||
const [turn] = parallelTurn();
|
||||
turn!.tool_calls = [
|
||||
{ id: 'y', type: 'function', function: { name: 'y', arguments: '{}' } },
|
||||
{ id: 'x', type: 'function', function: { name: 'x', arguments: '{}' } },
|
||||
];
|
||||
expect(
|
||||
callIds(
|
||||
await process_input_messages([turn!], {
|
||||
preserveToolCallOrder: true,
|
||||
}),
|
||||
),
|
||||
).toEqual(['y', 'x', 'a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces tool_result blocks into a top-level tool message', async () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
costsForRequest,
|
||||
isFreeModel,
|
||||
isOutputCostKey,
|
||||
listedCosts,
|
||||
longContextMultipliers,
|
||||
trackedInputTokens,
|
||||
trackedOutputTokens,
|
||||
@@ -519,3 +520,89 @@ describe('costsForRequest prompt-size overrides', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('costsForRequest start-instant overrides', () => {
|
||||
const repriced: IChatModel = {
|
||||
...model({ prompt_tokens: 200, completion_tokens: 1000 }),
|
||||
pricingOverrides: [
|
||||
{
|
||||
startsAt: '2026-12-01T00:00:00Z',
|
||||
costs: { prompt_tokens: 400, completion_tokens: 2000 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it.each([
|
||||
['2026-11-30T23:59:59.999Z', 200, 1000],
|
||||
['2026-12-01T00:00:00.000Z', 400, 2000],
|
||||
['2027-03-01T12:00:00.000Z', 400, 2000],
|
||||
])('at %s charges input %i and output %i', (iso, input, output) => {
|
||||
const costs = costsForRequest(repriced, 10, new Date(iso));
|
||||
expect(costs.prompt_tokens).toBe(input);
|
||||
expect(costs.completion_tokens).toBe(output);
|
||||
});
|
||||
|
||||
it('prices usage at the instant it is given, not the current time', () => {
|
||||
const usage = { prompt_tokens: 10, completion_tokens: 1 };
|
||||
const before = new Date('2026-11-30T23:59:59Z');
|
||||
const after = new Date('2026-12-01T00:00:01Z');
|
||||
expect(buildCostsOverride(usage, repriced, before)).toEqual({
|
||||
prompt_tokens: 2000,
|
||||
completion_tokens: 1000,
|
||||
});
|
||||
expect(usageCost(usage, repriced, undefined, before)).toEqual({
|
||||
inputMicroCents: 2000,
|
||||
outputMicroCents: 1000,
|
||||
});
|
||||
expect(usageCost(usage, repriced, undefined, after)).toEqual({
|
||||
inputMicroCents: 4000,
|
||||
outputMicroCents: 2000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listedCosts', () => {
|
||||
const before = new Date('2026-11-30T23:59:59Z');
|
||||
const after = new Date('2026-12-01T00:00:00Z');
|
||||
|
||||
it('lists the rates a scheduled change puts in force', () => {
|
||||
const repriced: IChatModel = {
|
||||
...model({ prompt_tokens: 200, completion_tokens: 1000 }),
|
||||
pricingOverrides: [
|
||||
{
|
||||
startsAt: '2026-12-01T00:00:00Z',
|
||||
costs: { prompt_tokens: 400, completion_tokens: 2000 },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(listedCosts(repriced, before)).toEqual({
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 1000,
|
||||
});
|
||||
expect(listedCosts(repriced, after)).toEqual({
|
||||
prompt_tokens: 400,
|
||||
completion_tokens: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves out rates that depend on the request', () => {
|
||||
const conditional: IChatModel = {
|
||||
...model({ prompt_tokens: 100 }),
|
||||
pricingOverrides: [
|
||||
{ minPromptTokens: 0, costs: { prompt_tokens: 300 } },
|
||||
{
|
||||
utcStart: 0,
|
||||
utcEnd: 2400,
|
||||
costs: { prompt_tokens: 50 },
|
||||
},
|
||||
{
|
||||
startsAt: '2026-12-01T00:00:00Z',
|
||||
utcDays: [0, 1, 2, 3, 4, 5, 6],
|
||||
costs: { prompt_tokens: 900 },
|
||||
},
|
||||
],
|
||||
};
|
||||
// The same object, so unaffected listings are unchanged.
|
||||
expect(listedCosts(conditional, after)).toBe(conditional.costs);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { IChatModel, ModelCost, UsageDetails } from '../types.js';
|
||||
import type {
|
||||
IChatModel,
|
||||
ModelCost,
|
||||
ModelPricingOverride,
|
||||
UsageDetails,
|
||||
} from '../types.js';
|
||||
|
||||
/**
|
||||
* Absent, negative and placeholder prices remain unpriced, including empty
|
||||
@@ -101,6 +106,11 @@ export const costsForRequest = (
|
||||
continue;
|
||||
if (override.utcDays && !override.utcDays.includes(now.getUTCDay()))
|
||||
continue;
|
||||
if (
|
||||
override.startsAt !== undefined &&
|
||||
!(now.getTime() >= Date.parse(override.startsAt))
|
||||
)
|
||||
continue;
|
||||
if (override.utcStart !== undefined && override.utcEnd !== undefined) {
|
||||
const inWindow =
|
||||
override.utcStart < override.utcEnd
|
||||
@@ -113,6 +123,25 @@ export const costsForRequest = (
|
||||
return costs;
|
||||
};
|
||||
|
||||
const isScheduledOnly = (override: ModelPricingOverride): boolean =>
|
||||
override.startsAt !== undefined &&
|
||||
override.minPromptTokens === undefined &&
|
||||
override.utcDays === undefined &&
|
||||
override.utcStart === undefined &&
|
||||
override.utcEnd === undefined;
|
||||
|
||||
/**
|
||||
* The rates a model lists at `now`: scheduled price changes applied, rates that
|
||||
* depend on the request (prompt size, time of day) not. `model.costs` itself
|
||||
* when the model has no scheduled change.
|
||||
*/
|
||||
export const listedCosts = (model: IChatModel, now = new Date()): ModelCost => {
|
||||
const scheduled = (model.pricingOverrides ?? []).filter(isScheduledOnly);
|
||||
return scheduled.length
|
||||
? costsForRequest({ ...model, pricingOverrides: scheduled }, 0, now)
|
||||
: model.costs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a usage key is priced at the output rate when the model has no rate
|
||||
* of its own for it.
|
||||
@@ -331,15 +360,16 @@ export const buildCostsOverride = (
|
||||
* What a usage object costs in µ¢, split into its input and output sides.
|
||||
* `recordedCosts` are the per-key costs a provider already metered with and win
|
||||
* over the model's rates, so the figure matches the ledger. Null when nothing
|
||||
* in the usage could be priced.
|
||||
* in the usage could be priced. `now` is the instant the request was priced at.
|
||||
*/
|
||||
export const usageCost = (
|
||||
usage: Record<string, unknown>,
|
||||
model: IChatModel,
|
||||
recordedCosts?: Record<string, number>,
|
||||
now?: Date,
|
||||
): { inputMicroCents: number; outputMicroCents: number } | null => {
|
||||
const { outputKey } = costKeys(model);
|
||||
const priced = priceUsage(usage, model);
|
||||
const priced = priceUsage(usage, model, now);
|
||||
let input = 0;
|
||||
let output = 0;
|
||||
let sawAnyCost = false;
|
||||
|
||||
@@ -40,10 +40,12 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { SYSTEM_ACTOR } from '../../core/actor.js';
|
||||
import { type Actor, SYSTEM_ACTOR } from '../../core/actor.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './providers/cloudflare/models.js';
|
||||
import cloudflareModelSearchFixture from './providers/cloudflare/fixtures/model-search.json';
|
||||
import { GEMINI_IMAGE_GENERATION_MODELS } from './providers/gemini/models.js';
|
||||
@@ -981,6 +983,56 @@ describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
expect(result).toBe('https://rp/img.png');
|
||||
expect(createPredictionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('registers Kling from the shared `kling` key and routes its models there', async () => {
|
||||
const klingDriver = new ImageGenerationDriver(
|
||||
{
|
||||
providers: {
|
||||
'openai-image-generation': { apiKey: 'oai-key' },
|
||||
kling: { apiKey: 'kl-key' },
|
||||
},
|
||||
} as never,
|
||||
server.clients,
|
||||
server.stores,
|
||||
server.services,
|
||||
);
|
||||
await klingDriver.onServerStart();
|
||||
const logged = () =>
|
||||
eventEmitSpy.mock.calls.filter(([key]) => key === 'ai.log.image')
|
||||
.at(-1)?.[1];
|
||||
|
||||
await withActor(() =>
|
||||
klingDriver.generate({
|
||||
provider: 'kling',
|
||||
prompt: 'hi',
|
||||
test_mode: true,
|
||||
}),
|
||||
);
|
||||
expect(logged()).toMatchObject({
|
||||
model_used: 'kling-v3',
|
||||
service_used: 'kling-image-generation',
|
||||
});
|
||||
|
||||
await withActor(() =>
|
||||
klingDriver.generate({
|
||||
model: 'kling:kling/kling-v3-omni',
|
||||
prompt: 'hi',
|
||||
test_mode: true,
|
||||
}),
|
||||
);
|
||||
expect(logged()).toMatchObject({
|
||||
model_used: 'kling-v3-omni',
|
||||
service_used: 'kling-image-generation',
|
||||
});
|
||||
|
||||
// Kling registers after OpenAI, so it never becomes the default.
|
||||
await withActor(() =>
|
||||
klingDriver.generate({ prompt: 'hi', test_mode: true }),
|
||||
);
|
||||
expect(logged()).toMatchObject({
|
||||
service_used: 'openai-image-generation',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Ratio normalization ────────────────────────────────────────────
|
||||
@@ -1027,6 +1079,126 @@ describe('ImageGenerationDriver.generate ratio normalization', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Puter file image inputs ───────────────────────────────────────
|
||||
|
||||
describe('ImageGenerationDriver.generate Puter file inputs', () => {
|
||||
const PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
||||
|
||||
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
|
||||
const username = `image-media-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
created,
|
||||
);
|
||||
const user = (await server.stores.user.getById(created.id))!;
|
||||
return {
|
||||
userId: user.id,
|
||||
actor: {
|
||||
user: {
|
||||
id: user.id,
|
||||
uuid: user.uuid,
|
||||
username: user.username,
|
||||
email: user.email ?? null,
|
||||
email_confirmed: true,
|
||||
} as Actor['user'],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const writeImage = async (
|
||||
{ actor, userId }: { actor: Actor; userId: number },
|
||||
name: string,
|
||||
) => {
|
||||
const path = `/${actor.user.username}/${name}`;
|
||||
const bytes = Buffer.from(PNG_BASE64, 'base64');
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: bytes.byteLength,
|
||||
contentType: 'application/octet-stream',
|
||||
},
|
||||
fileContent: bytes,
|
||||
});
|
||||
return path;
|
||||
};
|
||||
|
||||
const generateAs = (actor: Actor, args: Record<string, unknown>) =>
|
||||
Promise.resolve(
|
||||
runWithContext({ actor }, () =>
|
||||
driver.generate({
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-pro-image',
|
||||
prompt: 'hi',
|
||||
...args,
|
||||
} as never),
|
||||
),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(server.services.metering, 'hasEnoughCredits').mockResolvedValue(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends Puter file images to the provider, leaving the caller's array and the audit log on the paths", async () => {
|
||||
const user = await makeUser();
|
||||
const path = await writeImage(user, 'input.tmp');
|
||||
const inputImages = [path];
|
||||
googleAIGenerateContentMock.mockResolvedValueOnce({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{ inlineData: { data: 'aW1n', mimeType: 'image/png' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await generateAs(user.actor, { input_images: inputImages });
|
||||
|
||||
const sent = JSON.stringify(
|
||||
googleAIGenerateContentMock.mock.calls[0]![0].contents,
|
||||
);
|
||||
expect(sent).toContain(PNG_BASE64);
|
||||
expect(sent).not.toContain(path);
|
||||
expect(inputImages).toEqual([path]);
|
||||
const [, log] = eventEmitSpy.mock.calls.find(
|
||||
([eventName]) => eventName === 'ai.log.image',
|
||||
)!;
|
||||
expect(
|
||||
(log as { parameters: { input_images: string[] } }).parameters
|
||||
.input_images,
|
||||
).toEqual([path]);
|
||||
expect(secureFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses another user's image before calling the provider", async () => {
|
||||
const owner = await makeUser();
|
||||
const intruder = await makeUser();
|
||||
const path = await writeImage(owner, 'private.png');
|
||||
const status = await generateAs(intruder.actor, {
|
||||
input_image: path,
|
||||
}).then(
|
||||
() => null,
|
||||
(err: { statusCode?: number }) => err.statusCode,
|
||||
);
|
||||
expect([403, 404]).toContain(status);
|
||||
expect(googleAIGenerateContentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Audit log ──────────────────────────────────────────────────────
|
||||
|
||||
describe('ImageGenerationDriver.generate audit log', () => {
|
||||
|
||||
@@ -32,12 +32,14 @@ import {
|
||||
resolveOutputPath,
|
||||
saveGeneratedMediaToFS,
|
||||
} from '../util/generatedMedia.js';
|
||||
import { readPuterImageInputs } from '../util/mediaInput.js';
|
||||
import { ModelCatalog } from '../util/modelCatalog.js';
|
||||
import { CATALOG_TTL_MS } from '../ai-chat/utils/cachedRemoteCatalog.js';
|
||||
import { readProviderKey } from '../util/providerRegistry.js';
|
||||
import { BytePlusImageProvider } from './providers/byteplus/BytePlusImageProvider.js';
|
||||
import { CloudflareImageProvider } from './providers/cloudflare/CloudflareImageProvider.js';
|
||||
import { GeminiImageProvider } from './providers/gemini/GeminiImageProvider.js';
|
||||
import { KlingImageProvider } from './providers/kling/KlingImageProvider.js';
|
||||
import { OpenAiImageProvider } from './providers/openai/OpenAiImageProvider.js';
|
||||
import { ReplicateImageGenerationProvider } from './providers/replicate/ReplicateImageGenerationProvider.js';
|
||||
import { resolveImageSize } from './imageDimensions.js';
|
||||
@@ -72,6 +74,7 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
'xai-image-generation',
|
||||
'replicate-image-generation',
|
||||
'byteplus-image-generation',
|
||||
'kling-image-generation',
|
||||
];
|
||||
readonly isDefault = true;
|
||||
|
||||
@@ -328,7 +331,12 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
{},
|
||||
);
|
||||
|
||||
const result = await provider.generate(request);
|
||||
// After the audit log, which records the paths rather than the bytes.
|
||||
const result = await provider.generate(
|
||||
request.test_mode
|
||||
? request
|
||||
: await this.#withPuterImagesInlined(actor, request),
|
||||
);
|
||||
|
||||
if (resolvedOutputPath) {
|
||||
await saveGeneratedMediaToFS(
|
||||
@@ -343,6 +351,34 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* `request` with image inputs given as Puter file paths read within the
|
||||
* caller's access and replaced by data URIs. `request` is left as is.
|
||||
*/
|
||||
async #withPuterImagesInlined(
|
||||
actor: Actor,
|
||||
request: IGenerateParams,
|
||||
): Promise<IGenerateParams> {
|
||||
const { input_image: inputImage, input_images: inputImages } = request;
|
||||
const inlined = await readPuterImageInputs(
|
||||
{ stores: this.stores, fs: this.services.fs },
|
||||
actor,
|
||||
[inputImage, request.maskImage, ...(inputImages ?? [])],
|
||||
);
|
||||
if (inlined.size === 0) return request;
|
||||
|
||||
const swap = <T>(value: T): T | string =>
|
||||
(typeof value === 'string' && inlined.get(value)) || value;
|
||||
return {
|
||||
...request,
|
||||
...(inputImage && { input_image: swap(inputImage) }),
|
||||
...(inputImages && { input_images: inputImages.map(swap) }),
|
||||
...(request.maskImage != null && {
|
||||
maskImage: swap(request.maskImage),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
#registerProviders() {
|
||||
const providers = this.config.providers ?? {};
|
||||
const m = this.#aiMetering;
|
||||
@@ -445,6 +481,20 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
m,
|
||||
);
|
||||
}
|
||||
|
||||
// One `kling` key covers video, image and TTS.
|
||||
const klingCfg = (providers['kling-image-generation'] ??
|
||||
providers['kling']) as Record<string, unknown> | undefined;
|
||||
const klingKey = readProviderKey(klingCfg);
|
||||
if (klingKey) {
|
||||
this.#providers['kling-image-generation'] = new KlingImageProvider(
|
||||
{
|
||||
apiKey: klingKey,
|
||||
apiBaseUrl: klingCfg?.apiBaseUrl as string | undefined,
|
||||
},
|
||||
m,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuilds the model map; overlapping callers share one rebuild. */
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Integration test for the Kling image provider.
|
||||
*
|
||||
* Generates one 1K image on kling-v2-1 ($0.014/image). Skipped when
|
||||
* `PUTER_TEST_AI_KLING_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { KlingImageProvider } from './KlingImageProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_KLING_API_KEY';
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'KlingImageProvider (integration)',
|
||||
() => {
|
||||
it(
|
||||
'generates an image URL from kling-v2-1',
|
||||
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const provider = new KlingImageProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
|
||||
const url = await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'kling-v2-1',
|
||||
prompt: 'a single red dot on a white background',
|
||||
quality: '1k',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(url.startsWith('https://')).toBe(true);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Offline unit tests for KlingImageProvider.
|
||||
*
|
||||
* Boots a real PuterServer and constructs the provider against the live wired
|
||||
* `MeteringService`. Kling's task API has no SDK; the provider calls it through
|
||||
* global `fetch`, which is stubbed as the network egress point.
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { withAiCostFactor } from '../../../util/aiCostFactor.js';
|
||||
import type { IGenerateParams } from '../../types.js';
|
||||
import { KlingImageProvider } from './KlingImageProvider.js';
|
||||
|
||||
// -- Test harness ----------------------------------------------------
|
||||
|
||||
let server: PuterServer;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeProvider = () =>
|
||||
new KlingImageProvider(
|
||||
{ apiKey: 'test-key', pollIntervalMs: 1 },
|
||||
withAiCostFactor(
|
||||
server.services.metering,
|
||||
server.clients.event,
|
||||
'ai-image',
|
||||
),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
incrementUsageSpy.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const envelope = (data: unknown, code = 0, status = 200) =>
|
||||
new Response(JSON.stringify({ code, message: 'msg', data }), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
const task = (status: string, extra: Record<string, unknown> = {}) => ({
|
||||
task_id: 'task-1',
|
||||
task_status: status,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const succeeded = task('succeed', {
|
||||
task_result: { images: [{ index: 0, url: 'https://kling.example/1.png' }] },
|
||||
});
|
||||
|
||||
/** Queue the create response, then each poll response. */
|
||||
const mockFlow = (...polls: Response[]) => {
|
||||
fetchSpy.mockResolvedValueOnce(envelope(task('submitted')));
|
||||
for (const poll of polls) fetchSpy.mockResolvedValueOnce(poll);
|
||||
};
|
||||
|
||||
const call = (i: number) => {
|
||||
const [url, init] = fetchSpy.mock.calls[i]!;
|
||||
return {
|
||||
url: String(url),
|
||||
method: (init as RequestInit).method,
|
||||
body: (init as RequestInit).body
|
||||
? JSON.parse((init as RequestInit).body as string)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const generate = (params: Partial<IGenerateParams>) =>
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'a red apple', ...params }),
|
||||
);
|
||||
|
||||
// -- Construction ----------------------------------------------------
|
||||
|
||||
describe('KlingImageProvider construction', () => {
|
||||
it('throws when no apiKey is supplied', () => {
|
||||
expect(
|
||||
() =>
|
||||
new KlingImageProvider(
|
||||
{ apiKey: '' },
|
||||
withAiCostFactor(
|
||||
server.services.metering,
|
||||
server.clients.event,
|
||||
'ai-image',
|
||||
),
|
||||
),
|
||||
).toThrow(/API key/i);
|
||||
});
|
||||
|
||||
it('defaults to Kling Image 3.0', () => {
|
||||
expect(makeProvider().getDefaultModel()).toBe('kling-v3');
|
||||
});
|
||||
});
|
||||
|
||||
// -- Request shape ---------------------------------------------------
|
||||
|
||||
describe('KlingImageProvider request', () => {
|
||||
it('creates a text-to-image task and returns the polled image URL', async () => {
|
||||
mockFlow(envelope(task('processing')), envelope(succeeded));
|
||||
|
||||
const url = await generate({});
|
||||
|
||||
expect(url).toBe('https://kling.example/1.png');
|
||||
const create = call(0);
|
||||
expect(create.url).toBe(
|
||||
'https://api-singapore.klingai.com/v1/images/generations',
|
||||
);
|
||||
expect(create.method).toBe('POST');
|
||||
expect(create.body).toEqual({
|
||||
model_name: 'kling-v3',
|
||||
prompt: 'a red apple',
|
||||
n: 1,
|
||||
resolution: '1k',
|
||||
aspect_ratio: '1:1',
|
||||
});
|
||||
expect(call(1).url).toBe(
|
||||
'https://api-singapore.klingai.com/v1/images/generations/task-1',
|
||||
);
|
||||
expect(call(1).method).toBe('GET');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
const headers = (fetchSpy.mock.calls[0]![1] as RequestInit)
|
||||
.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe('Bearer test-key');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['high', '2k'],
|
||||
['2K', '2k'],
|
||||
['low', '1k'],
|
||||
['ultra', '1k'],
|
||||
])('maps quality %s to the %s tier', async (quality, tier) => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ quality });
|
||||
expect(call(0).body.resolution).toBe(tier);
|
||||
});
|
||||
|
||||
it('takes 4k only on Image 3.0 Omni', async () => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ model: 'kling-v3-omni', quality: '4k' });
|
||||
expect(call(0).body.resolution).toBe('4k');
|
||||
|
||||
fetchSpy.mockReset();
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ model: 'kling-v3', quality: '4k' });
|
||||
expect(call(0).body.resolution).toBe('1k');
|
||||
});
|
||||
|
||||
it('snaps the requested size to the closest Kling aspect ratio', async () => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ imageSize: { w: 1920, h: 1000, kind: 'pixels' } });
|
||||
expect(call(0).body.aspect_ratio).toBe('16:9');
|
||||
|
||||
fetchSpy.mockReset();
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ imageSize: { w: 2, h: 3, kind: 'aspect' } });
|
||||
expect(call(0).body.aspect_ratio).toBe('2:3');
|
||||
});
|
||||
|
||||
it('sends a negative prompt only without a reference image', async () => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ negative_prompt: 'blur' });
|
||||
expect(call(0).body.negative_prompt).toBe('blur');
|
||||
|
||||
fetchSpy.mockReset();
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({
|
||||
negative_prompt: 'blur',
|
||||
input_image: 'https://example.com/a.png',
|
||||
});
|
||||
expect(call(0).body.negative_prompt).toBeUndefined();
|
||||
expect(call(0).body.image).toBe('https://example.com/a.png');
|
||||
});
|
||||
|
||||
it('sends a data URI reference image as raw base64', async () => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ input_images: ['data:image/png;base64,iVBORw0KGgo'] });
|
||||
expect(call(0).body.image).toBe('iVBORw0KGgo');
|
||||
});
|
||||
|
||||
it.each(['kling-v3-omni', 'kling-image-o1'])(
|
||||
'sends %s reference images to omni-image with an auto ratio',
|
||||
async (model) => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({
|
||||
model,
|
||||
input_images: [
|
||||
'https://example.com/a.png',
|
||||
'data:image/jpeg;base64,/9j/4AAQ',
|
||||
],
|
||||
});
|
||||
const create = call(0);
|
||||
expect(create.url).toBe(
|
||||
'https://api-singapore.klingai.com/v1/images/omni-image',
|
||||
);
|
||||
expect(create.body).toMatchObject({
|
||||
model_name: model,
|
||||
aspect_ratio: 'auto',
|
||||
image_list: [
|
||||
{ image: 'https://example.com/a.png' },
|
||||
{ image: '/9j/4AAQ' },
|
||||
],
|
||||
});
|
||||
expect(call(1).url).toBe(
|
||||
'https://api-singapore.klingai.com/v1/images/omni-image/task-1',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects more input images than the model takes before any fetch', async () => {
|
||||
await expect(
|
||||
generate({
|
||||
model: 'kling-v3',
|
||||
input_images: ['https://e.com/a.png', 'https://e.com/b.png'],
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the sample image in test mode without calling Kling', async () => {
|
||||
const url = await generate({ test_mode: true });
|
||||
expect(url).toMatch(/^https:\/\//);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a transient poll failure', async () => {
|
||||
mockFlow(
|
||||
envelope(undefined, 5000, 500),
|
||||
envelope(task('processing')),
|
||||
envelope(succeeded),
|
||||
);
|
||||
await expect(generate({})).resolves.toBe('https://kling.example/1.png');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Billing ---------------------------------------------------------
|
||||
|
||||
describe('KlingImageProvider billing', () => {
|
||||
it('meters one image at the tier rate', async () => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ model: 'kling-v3-omni', quality: '4k' });
|
||||
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
|
||||
const [, usageType, amount, cost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(usageType).toBe('kling-image-generation:kling-v3-omni:4k');
|
||||
expect(amount).toBe(1);
|
||||
expect(cost).toBe(5.6 * 1_000_000);
|
||||
});
|
||||
|
||||
it('bills Image 2.1 image-to-image at its own rate', async () => {
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({
|
||||
model: 'kling-v2-1',
|
||||
input_image: 'https://example.com/a.png',
|
||||
});
|
||||
const [, usageType, , cost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(usageType).toBe(
|
||||
'kling-image-generation:kling-v2-1:1k:image-to-image',
|
||||
);
|
||||
expect(cost).toBe(2.8 * 1_000_000);
|
||||
|
||||
incrementUsageSpy.mockClear();
|
||||
fetchSpy.mockReset();
|
||||
mockFlow(envelope(succeeded));
|
||||
await generate({ model: 'kling-v2-1' });
|
||||
const [, textUsage, , textCost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(textUsage).toBe('kling-image-generation:kling-v2-1:1k');
|
||||
expect(textCost).toBe(1.4 * 1_000_000);
|
||||
});
|
||||
|
||||
it('rejects before calling Kling when credits run short', async () => {
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
await expect(generate({})).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a content-policy failure as moderation_flagged, unbilled', async () => {
|
||||
mockFlow(
|
||||
envelope(
|
||||
task('failed', {
|
||||
task_status_msg:
|
||||
'Triggering the content risk control of the platform',
|
||||
}),
|
||||
),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
code: 'moderation_flagged',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports any other failed task as upstream_failed, unbilled', async () => {
|
||||
mockFlow(
|
||||
envelope(task('failed', { task_status_msg: 'internal error' })),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { insufficientCreditsError } from '../../../../services/metering/enforcement.js';
|
||||
import type { AiMeteringService } from '../../../util/aiCostFactor.js';
|
||||
import { klingImageInput, klingRequest } from '../../../util/kling.js';
|
||||
import {
|
||||
CONTENT_FILTER_PATTERN,
|
||||
isTransientUpstreamError,
|
||||
sanitizeUpstreamMessage,
|
||||
} from '../../../util/upstreamErrors.js';
|
||||
import {
|
||||
closestAspectRatio,
|
||||
formatAspectRatio,
|
||||
} from '../../imageDimensions.js';
|
||||
import { assertImagePrompt } from '../../imageValidation.js';
|
||||
import type { IGenerateParams, IImageProvider } from '../../types.js';
|
||||
import {
|
||||
KLING_IMAGE_GENERATION_MODELS,
|
||||
type KlingImageModel,
|
||||
} from './models.js';
|
||||
|
||||
const DEFAULT_MODEL = 'kling-v3';
|
||||
const MAX_PROMPT_CHARS = 2500;
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
const POLL_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
type KlingImageConfig = {
|
||||
apiKey: string;
|
||||
apiBaseUrl?: string;
|
||||
/** Test hook: polling cadence for task status checks. */
|
||||
pollIntervalMs?: number;
|
||||
};
|
||||
|
||||
interface KlingImageTask {
|
||||
task_id: string;
|
||||
task_status: 'submitted' | 'processing' | 'succeed' | 'failed';
|
||||
task_status_msg?: string;
|
||||
task_result?: { images?: Array<{ url?: string }> };
|
||||
}
|
||||
|
||||
const badRequest = (message: string) =>
|
||||
new HttpError(400, message, { legacyCode: 'bad_request' });
|
||||
|
||||
/**
|
||||
* Kling AI image generation (Image 3.0, 3.0 Omni, O1, 2.1).
|
||||
*
|
||||
* Kling's image API is task-based: the create call returns a task id, which is
|
||||
* polled until it settles. Billed per image at the resolution tier's rate.
|
||||
* https://kling.ai/document-api/api/image/3-0-omni/image-generation
|
||||
*/
|
||||
export class KlingImageProvider implements IImageProvider {
|
||||
#apiKey: string;
|
||||
#baseUrl: string | undefined;
|
||||
#pollIntervalMs: number;
|
||||
#meteringService: AiMeteringService;
|
||||
|
||||
constructor(config: KlingImageConfig, meteringService: AiMeteringService) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error('Kling image generation requires an API key');
|
||||
}
|
||||
this.#apiKey = config.apiKey;
|
||||
this.#baseUrl = config.apiBaseUrl?.replace(/\/$/, '');
|
||||
this.#pollIntervalMs = config.pollIntervalMs ?? POLL_INTERVAL_MS;
|
||||
this.#meteringService = meteringService;
|
||||
}
|
||||
|
||||
models(): KlingImageModel[] {
|
||||
return KLING_IMAGE_GENERATION_MODELS;
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
async generate(params: IGenerateParams): Promise<string> {
|
||||
const {
|
||||
prompt,
|
||||
test_mode: testMode,
|
||||
quality,
|
||||
negative_prompt,
|
||||
} = params;
|
||||
const model = this.#getModel(params.model);
|
||||
|
||||
if (testMode) {
|
||||
return 'https://puter-sample-data.puter.site/image_example.png';
|
||||
}
|
||||
|
||||
assertImagePrompt(prompt);
|
||||
if (prompt.length > MAX_PROMPT_CHARS) {
|
||||
throw badRequest(
|
||||
`Kling takes a prompt of at most ${MAX_PROMPT_CHARS} characters`,
|
||||
);
|
||||
}
|
||||
|
||||
const inputs = (
|
||||
params.input_images?.length
|
||||
? params.input_images
|
||||
: params.input_image
|
||||
? [params.input_image]
|
||||
: []
|
||||
).map(klingImageInput);
|
||||
if (inputs.length > model.maxInputImages) {
|
||||
throw badRequest(
|
||||
`${model.id} accepts at most ${model.maxInputImages} input image(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
const tier = this.#tier(quality, model);
|
||||
const costKey =
|
||||
inputs.length > 0 && model.costs[`${tier}:image-to-image`]
|
||||
? `${tier}:image-to-image`
|
||||
: tier;
|
||||
const cents = model.costs[costKey];
|
||||
const usageType = `kling-image-generation:${model.id}:${costKey}`;
|
||||
|
||||
const actor = Context.get('actor');
|
||||
if (!actor) {
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
}
|
||||
const hold = await this.#meteringService.reserveAiCredits(
|
||||
actor,
|
||||
usageType,
|
||||
Math.round(cents * 1_000_000),
|
||||
);
|
||||
if (!hold) throw insufficientCreditsError();
|
||||
|
||||
try {
|
||||
const aspectRatio = params.imageSize
|
||||
? formatAspectRatio(
|
||||
closestAspectRatio(
|
||||
params.imageSize,
|
||||
model.allowedRatios!,
|
||||
),
|
||||
)
|
||||
: inputs.length > 0 && model.autoRatio
|
||||
? 'auto'
|
||||
: '1:1';
|
||||
const body: Record<string, unknown> = {
|
||||
model_name: model.id,
|
||||
prompt,
|
||||
n: 1,
|
||||
resolution: tier,
|
||||
aspect_ratio: aspectRatio,
|
||||
};
|
||||
if (model.endpoint === 'omni-image') {
|
||||
if (inputs.length > 0) {
|
||||
body.image_list = inputs.map((image) => ({ image }));
|
||||
}
|
||||
} else if (inputs.length > 0) {
|
||||
body.image = inputs[0];
|
||||
} else if (
|
||||
typeof negative_prompt === 'string' &&
|
||||
negative_prompt.trim()
|
||||
) {
|
||||
// Kling takes a negative prompt only without a reference image.
|
||||
body.negative_prompt = negative_prompt;
|
||||
}
|
||||
|
||||
const path = `/v1/images/${model.endpoint}`;
|
||||
const created = await klingRequest<KlingImageTask>(
|
||||
this.#apiKey,
|
||||
'POST',
|
||||
path,
|
||||
{ body, baseUrl: this.#baseUrl },
|
||||
);
|
||||
const task = await this.#pollUntilSettled(path, created);
|
||||
if (task.task_status !== 'succeed') {
|
||||
throw this.#taskFailure(task.task_status_msg);
|
||||
}
|
||||
const url = task.task_result?.images?.[0]?.url;
|
||||
if (!url) {
|
||||
throw new Error('Kling response did not include an image URL');
|
||||
}
|
||||
|
||||
await this.#meteringService.incrementUsage(
|
||||
actor,
|
||||
usageType,
|
||||
1,
|
||||
Math.round(cents * 1_000_000),
|
||||
);
|
||||
return url;
|
||||
} finally {
|
||||
await hold.release();
|
||||
}
|
||||
}
|
||||
|
||||
async #pollUntilSettled(
|
||||
path: string,
|
||||
created: KlingImageTask,
|
||||
): Promise<KlingImageTask> {
|
||||
const signal = Context.get('abortSignal') as AbortSignal | undefined;
|
||||
const deadline = Date.now() + POLL_WINDOW_MS;
|
||||
let task = created;
|
||||
while (
|
||||
task.task_status === 'submitted' ||
|
||||
task.task_status === 'processing'
|
||||
) {
|
||||
if (signal?.aborted) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Client disconnected before image generation finished',
|
||||
{
|
||||
legacyCode: 'client_aborted',
|
||||
fields: { provider: 'kling' },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
throw new HttpError(
|
||||
504,
|
||||
'Timed out waiting for Kling image generation to complete',
|
||||
{
|
||||
legacyCode: 'upstream_timeout',
|
||||
fields: { provider: 'kling' },
|
||||
},
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, this.#pollIntervalMs),
|
||||
);
|
||||
try {
|
||||
task = await klingRequest<KlingImageTask>(
|
||||
this.#apiKey,
|
||||
'GET',
|
||||
`${path}/${encodeURIComponent(created.task_id)}`,
|
||||
{ baseUrl: this.#baseUrl },
|
||||
);
|
||||
} catch (err) {
|
||||
// A missed poll is not a failed task.
|
||||
if (!isTransientUpstreamError(err)) throw err;
|
||||
}
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* A content-policy refusal is the caller's to act on; anything else
|
||||
* Kling's.
|
||||
*/
|
||||
#taskFailure(message: string | undefined): HttpError {
|
||||
const detail =
|
||||
sanitizeUpstreamMessage(message ?? '') ||
|
||||
'Kling image generation failed';
|
||||
const fields = { provider: 'kling' };
|
||||
if (CONTENT_FILTER_PATTERN.test(detail)) {
|
||||
return new HttpError(400, detail, {
|
||||
legacyCode: 'bad_request',
|
||||
code: 'moderation_flagged',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
return new HttpError(502, detail, {
|
||||
legacyCode: 'upstream_failed',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
|
||||
/** Kling's `1k` / `2k` / `4k`, accepting other providers' quality names. */
|
||||
#tier(quality: string | undefined, model: KlingImageModel): string {
|
||||
const allowed = model.allowedQualityLevels!;
|
||||
const wanted = (quality ?? '').trim().toLowerCase();
|
||||
const synonym: Record<string, string> = {
|
||||
low: '1k',
|
||||
medium: '1k',
|
||||
high: '2k',
|
||||
hd: '2k',
|
||||
};
|
||||
const tier = allowed.includes(wanted) ? wanted : synonym[wanted];
|
||||
return tier && allowed.includes(tier) ? tier : allowed[0];
|
||||
}
|
||||
|
||||
#getModel(model?: string): KlingImageModel {
|
||||
const wanted = (model ?? '').trim().toLowerCase();
|
||||
return (
|
||||
KLING_IMAGE_GENERATION_MODELS.find(
|
||||
(m) =>
|
||||
m.id === wanted ||
|
||||
m.puterId === wanted ||
|
||||
m.aliases?.includes(wanted),
|
||||
) ??
|
||||
KLING_IMAGE_GENERATION_MODELS.find((m) => m.id === DEFAULT_MODEL)!
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { IImageModel } from '../../types.js';
|
||||
|
||||
export type KlingImageModel = IImageModel & {
|
||||
/** `generations` takes one reference image; `omni-image` takes several. */
|
||||
endpoint: 'generations' | 'omni-image';
|
||||
maxInputImages: number;
|
||||
/** Whether `aspect_ratio: 'auto'` may be sent with input images. */
|
||||
autoRatio: boolean;
|
||||
};
|
||||
|
||||
const RATIOS = [
|
||||
{ w: 16, h: 9 },
|
||||
{ w: 9, h: 16 },
|
||||
{ w: 1, h: 1 },
|
||||
{ w: 4, h: 3 },
|
||||
{ w: 3, h: 4 },
|
||||
{ w: 3, h: 2 },
|
||||
{ w: 2, h: 3 },
|
||||
{ w: 21, h: 9 },
|
||||
];
|
||||
|
||||
// usd-cents per image from https://kling.ai/document-api/pricing/base/image
|
||||
// (1 unit = $0.0035). Cost keys are the output resolution tier; Image 2.1
|
||||
// prices text-to-image and image-to-image apart. Capabilities per
|
||||
// https://kling.ai/document-api/guides/capability-map/image.
|
||||
export const KLING_IMAGE_GENERATION_MODELS: KlingImageModel[] = [
|
||||
{
|
||||
id: 'kling-v3',
|
||||
puterId: 'kling:kling/kling-v3',
|
||||
aliases: ['kling/kling-v3', 'kling-image-3.0'],
|
||||
name: 'Kling Image 3.0',
|
||||
version: '3.0',
|
||||
costs_currency: 'usd-cents',
|
||||
pricing_unit: 'per-tier',
|
||||
index_cost_key: '1k',
|
||||
costs: { '1k': 2.8, '2k': 2.8 },
|
||||
allowedQualityLevels: ['1k', '2k'],
|
||||
allowedRatios: RATIOS,
|
||||
endpoint: 'generations',
|
||||
maxInputImages: 1,
|
||||
autoRatio: false,
|
||||
},
|
||||
{
|
||||
id: 'kling-v3-omni',
|
||||
puterId: 'kling:kling/kling-v3-omni',
|
||||
aliases: ['kling/kling-v3-omni', 'kling-image-3.0-omni'],
|
||||
name: 'Kling Image 3.0 Omni',
|
||||
version: '3.0',
|
||||
costs_currency: 'usd-cents',
|
||||
pricing_unit: 'per-tier',
|
||||
index_cost_key: '1k',
|
||||
costs: { '1k': 2.8, '2k': 2.8, '4k': 5.6 },
|
||||
allowedQualityLevels: ['1k', '2k', '4k'],
|
||||
allowedRatios: RATIOS,
|
||||
endpoint: 'omni-image',
|
||||
maxInputImages: 10,
|
||||
autoRatio: true,
|
||||
},
|
||||
{
|
||||
id: 'kling-image-o1',
|
||||
puterId: 'kling:kling/kling-image-o1',
|
||||
aliases: ['kling/kling-image-o1'],
|
||||
name: 'Kling Image O1',
|
||||
version: 'o1',
|
||||
costs_currency: 'usd-cents',
|
||||
pricing_unit: 'per-tier',
|
||||
index_cost_key: '1k',
|
||||
costs: { '1k': 2.8, '2k': 2.8 },
|
||||
allowedQualityLevels: ['1k', '2k'],
|
||||
allowedRatios: RATIOS,
|
||||
endpoint: 'omni-image',
|
||||
maxInputImages: 10,
|
||||
autoRatio: true,
|
||||
},
|
||||
{
|
||||
id: 'kling-v2-1',
|
||||
puterId: 'kling:kling/kling-v2-1',
|
||||
aliases: ['kling/kling-v2-1', 'kling-image-2.1'],
|
||||
name: 'Kling Image 2.1',
|
||||
version: '2.1',
|
||||
costs_currency: 'usd-cents',
|
||||
pricing_unit: 'per-tier',
|
||||
index_cost_key: '1k',
|
||||
costs: {
|
||||
'1k': 1.4,
|
||||
'2k': 1.4,
|
||||
'1k:image-to-image': 2.8,
|
||||
'2k:image-to-image': 2.8,
|
||||
},
|
||||
allowedQualityLevels: ['1k', '2k'],
|
||||
allowedRatios: RATIOS,
|
||||
endpoint: 'generations',
|
||||
maxInputImages: 1,
|
||||
autoRatio: false,
|
||||
},
|
||||
];
|
||||
@@ -194,10 +194,10 @@ describe('SpeechToTextDriver.getReportedCosts', () => {
|
||||
it('mirrors every entry in costs.ts as a per-second line item', () => {
|
||||
const reported = driver.getReportedCosts();
|
||||
|
||||
// One extra line item comes from the xAI provider, which the
|
||||
// driver aggregates alongside OpenAI's catalogue.
|
||||
// The driver aggregates the xAI line item and Gemini's two token
|
||||
// rates alongside OpenAI's catalogue.
|
||||
expect(reported).toHaveLength(
|
||||
Object.keys(SPEECH_TO_TEXT_COSTS).length + 1,
|
||||
Object.keys(SPEECH_TO_TEXT_COSTS).length + 3,
|
||||
);
|
||||
for (const [usageType, ucentsPerUnit] of Object.entries(
|
||||
SPEECH_TO_TEXT_COSTS,
|
||||
|
||||
Loaded 100 of 352 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user