mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24fb679dd3 | ||
|
|
eb9f03e984 | ||
|
|
9292771554 | ||
|
|
f4974581d0 | ||
|
|
b39bb771d9 | ||
|
|
766379ae9f | ||
|
|
0be3bc55c2 | ||
|
|
d1484af754 | ||
|
|
51ce868d1e | ||
|
|
bb97660ad7 | ||
|
|
b2fef45fd6 | ||
|
|
ff333d05e8 | ||
|
|
194ca7c789 | ||
|
|
0b85203edb | ||
|
|
1b6334c02c | ||
|
|
05cbd33211 | ||
|
|
5c8f02384c | ||
|
|
79c856386f | ||
|
|
59f73528cb | ||
|
|
45aff36f0c | ||
|
|
a1f7577acb | ||
|
|
b136c56cb5 | ||
|
|
f428797a79 | ||
|
|
ad3d15e7e2 | ||
|
|
07bf0be9c7 | ||
|
|
d116f49669 | ||
|
|
a5f267c2d4 | ||
|
|
2fdd67c72e | ||
|
|
92dfe4019e | ||
|
|
61a6820767 | ||
|
|
38b131e5e8 | ||
|
|
230241d6d2 | ||
|
|
27dd0f8ce6 | ||
|
|
50d3794283 | ||
|
|
7fdbc0247f | ||
|
|
3bf5ba3525 | ||
|
|
018196f8b4 | ||
|
|
c3e2717e8e | ||
|
|
15527eac4c | ||
|
|
4a23c296f0 | ||
|
|
790db87e13 | ||
|
|
b901040c14 | ||
|
|
ed7e0b9cc4 | ||
|
|
883072d144 | ||
|
|
8d90418c9e | ||
|
|
de74b0c488 | ||
|
|
967b974fe9 | ||
|
|
93885dfc54 | ||
|
|
ab077f047b | ||
|
|
63f857ce3c | ||
|
|
480d6bc3b5 | ||
|
|
1efe086565 | ||
|
|
06bdc26efd | ||
|
|
c2a975b3f7 | ||
|
|
b8b0650990 | ||
|
|
bafe8af71b | ||
|
|
187e4c8a2f | ||
|
|
f2c9737bd8 | ||
|
|
5fbdc7cac5 | ||
|
|
3d2bdfceef | ||
|
|
2083ef47c0 | ||
|
|
9940beb4bb | ||
|
|
fa36f2e3cf | ||
|
|
efe6ef09bd | ||
|
|
367d20a55c | ||
|
|
e886e86cb5 | ||
|
|
106978e714 | ||
|
|
fb74789de1 | ||
|
|
a983969f32 | ||
|
|
b19b312e30 |
No files matched your search
@@ -20,7 +20,7 @@ jobs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
git reset --hard main
|
||||
- name: Create/Update PR
|
||||
if: steps.find-pr.outputs.number == ''
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: Update release branch
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
puterjs: ${{ steps.filter.outputs.puterjs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
|
||||
+10
-3
@@ -21,12 +21,19 @@ Out-of-scope:
|
||||
|
||||
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:
|
||||
|
||||
- **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we _do_ evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
|
||||
- **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is out of scope. This explicitly includes **missing `Content-Security-Policy: sandbox` on any endpoint that serves a file inline** — `/token-read`, `/file`, `/fs/read`, WebDAV, and any MIME type the `applyInlineContentSecurity` allow-list does not cover (`text/xsl`, `image/svg+xml`, `application/xhtml+xml`, …). Script execution on the API origin has nothing to reach: there is no session cookie, no `localStorage`, and no ambient credential there. WebDAV additionally has to return an accurate MIME type for real WebDAV clients. The one exception we _do_ evaluate on its merits is anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host — if your report does not name a specific credential that is actually reachable from the API origin, it is this non-issue.
|
||||
- **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
|
||||
- **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
|
||||
- **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.
|
||||
- **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
|
||||
- **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"disable_user_signup": false,
|
||||
"teams_enabled": false,
|
||||
"max_teams_per_user": 1,
|
||||
"max_seats_per_team": 50,
|
||||
"max_seats_per_team_free": 4,
|
||||
"max_seats_per_team_paid": 40,
|
||||
"strict_email_verification_required": false,
|
||||
"gui_assets_root": "./src/gui",
|
||||
"puterjs_root": "./src/puter-js/dist",
|
||||
|
||||
+20
-6
@@ -285,12 +285,26 @@
|
||||
// deleting yours frees the slot. Default 1.
|
||||
"max_teams_per_user": 1,
|
||||
//
|
||||
// Seats one team may provision. Each seat is a real Puter account
|
||||
// taking a name from the global username pool, so this is what bounds a
|
||||
// team's blast radius until per-seat billing lands. Lowering it below
|
||||
// a team's current seat count blocks new provisioning and disables
|
||||
// nobody. Default 50.
|
||||
"max_seats_per_team": 50,
|
||||
// Seats one team may provision. Each seat is a real Puter account taking a
|
||||
// name from the global username pool, so this is what bounds a team's blast
|
||||
// radius. The owner's plan decides which of the two applies; defaults are 4
|
||||
// and 40. Lowering either below a team's current seat count blocks new
|
||||
// provisioning and disables nobody. Deployments upgrading from the old flat
|
||||
// default of 50: without a billing extension every owner reads as free, so
|
||||
// set `max_seats_per_team` to keep the old behavior.
|
||||
"max_seats_per_team_free": 4,
|
||||
"max_seats_per_team_paid": 40,
|
||||
//
|
||||
// One flat cap for every team, whatever the owner pays. Setting it
|
||||
// overrides both of the above, so a deployment that does not sell seats can
|
||||
// ignore plans entirely. Unset by default — leave it that way to keep the
|
||||
// free cap meaningful.
|
||||
// "max_seats_per_team": 50,
|
||||
//
|
||||
// Staged rollout: only these email domains may create a team, and the tab
|
||||
// renders for them even with `gui_params.teams_ui` off. Members of an
|
||||
// existing team always pass. Unset means everyone, gated by `teams_ui`.
|
||||
// "teams_allowed_email_domains": ["puter.com"],
|
||||
|
||||
// ── Notifications ───────────────────────────────────────────────────
|
||||
// How long a notification is kept, in days from creation. Acknowledged or
|
||||
|
||||
@@ -59,6 +59,16 @@ We use [`Context`](../src/backend/core/context.ts) — backed by `AsyncLocalStor
|
||||
|
||||
Prefer explicit arguments. Reach for `Context` only when the value is truly request-scoped and would otherwise need to thread through many layers.
|
||||
|
||||
## Actors
|
||||
|
||||
An [`Actor`](../src/backend/core/actor.ts) is who a request is acting as. It has two app-shaped fields and they are not interchangeable: `app` is the app the actor carries *directly* (an app-under-user token), while `effectiveApp` is the app it ultimately acts as — its own, or the one that issued its access token. An access-token actor has no `app` of its own, so a gate that reads `app` answers "no app" for it and falls open.
|
||||
|
||||
`makeActor` resolves `effectiveApp` once at construction, and `assertResolvedActor` at the request edge rejects any actor that skipped it — so reading `effectiveApp` needs no fallback. Reach for `app` only where the direct app genuinely is the question, and say why in a comment.
|
||||
|
||||
- `actor.effectiveApp` — which app is acting. This is the default; nearly every scoping rule, namespace key and attribution wants it.
|
||||
- `isAppActor(actor)` — a direct app actor specifically. Use it where a token must *not* be treated as its issuer, e.g. the permission scanners that read an app's own grant rows.
|
||||
- `isPlainUserActor(actor)` — the account acting through nothing at all; `isAccountContext(actor)` for the wider "the account's own reach", which also admits a full-access token.
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions live alongside core ([packages/puter/extensions/](../extensions/)) and parallel the layered stack. They are meant for **non-crucial parts of the system** — things Puter still works without if removed.
|
||||
|
||||
@@ -56,7 +56,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
|
||||
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), `requireSubscription`, `requireReputation`, and `noUserSession`. The `/drivers/call` surface enforces them.
|
||||
- **A surface only paying accounts should reach** declares `requireSubscription` — the route option on a controller endpoint, the per-method `@Driver({ requireSubscription })` block on a driver (`/drivers/call` is one shared route, so a driver's requirement can't live in route options). `true` accepts any plan that isn't free, so a plan registered by an extension counts without core naming it; an array of policy ids (`['business', 'pro']`) accepts only those; `false` is "no requirement", the same as leaving it out. An empty array is a boot error rather than a silent no-op — it reads as subscribers-only while admitting everyone. Off unless asked for, and answered from the metering service's cached per-actor subscription, so it costs a map lookup. Distinct from `requireCredits`: this asks which plan the account is on, not whether it has budget left. Deployments with no paid plans (self-hosted installs) turn the whole thing off with `meteringEnforcement.subscriptions: false`.
|
||||
- **A surface only a trusted-enough account should reach** declares `requireReputation` — the route option on a controller endpoint, the per-method `@Driver({ requireReputation })` block on a driver. The value names a tier; what that tier takes is `reputationGate.tiers` in config, so the score a surface is worth is a deployment call and can be retuned without editing the surface. A tier the running config doesn't define is inert and everyone passes — an install that doesn't score its accounts must not start turning traffic away on a score it never computed — and `reputationGate.enabled: false` stops every declared gate at once. Opt-in, implies `requireAuth`, and denies with a bare 403 `reputation_required` that names neither the score nor the tier. Nothing in the tree declares one yet: the mechanism ships ahead of the enrollment.
|
||||
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for.
|
||||
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually 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 where `card` is listed (a paying account has a card on file already); otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on), and with none verifiable the gate is inert rather than locking the route on a self-hosted install. It denies with 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, for an SMS-provider outage.
|
||||
- **An endpoint that spends metered resources** on the caller's behalf — moving file content, making object-store requests, anything else the account is billed for — also declares `requireCredits: true`, which turns an account with nothing left of its budget 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 be able to see what it has, clear it, and reach its billing pages. Drivers have no route options to declare this on, 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.
|
||||
|
||||
### 3. puter.js
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { makeActor } from '../src/backend/core/actor.ts';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { configContainer } from '../src/backend/exports.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
@@ -48,6 +49,7 @@ beforeAll(async () => {
|
||||
create_shortcut: true,
|
||||
payment_bypass: true,
|
||||
},
|
||||
teams_enabled: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
@@ -66,6 +68,81 @@ const seedUser = async () => {
|
||||
};
|
||||
|
||||
describe('whoami extension — handleWhoami', () => {
|
||||
// The sidebar label needs this at boot, which is why it rides whoami
|
||||
// rather than a call of its own.
|
||||
describe('the team an account belongs to', () => {
|
||||
// A seat is created, never adopted, so it must have no password.
|
||||
const seedSeat = async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
return server.stores.user.create({
|
||||
username: `wseat_${slug}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: null,
|
||||
});
|
||||
};
|
||||
|
||||
const seatOf = async (teamName: string) => {
|
||||
const owner = await seedUser();
|
||||
const seat = await seedSeat();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id as number,
|
||||
name: teamName,
|
||||
handle: `wt-${Math.random().toString(36).slice(2, 9)}`,
|
||||
});
|
||||
await server.stores.team.addMember(team.uid, seat.id as number, {
|
||||
orgOwned: true,
|
||||
});
|
||||
return { seat, team };
|
||||
};
|
||||
|
||||
it('names the team for a seat', async () => {
|
||||
const { seat, team } = await seatOf('Acme Corp');
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: seat.uuid, id: seat.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
expect((captured.body as { team?: unknown }).team).toEqual({
|
||||
uid: team.uid,
|
||||
name: 'Acme Corp',
|
||||
});
|
||||
});
|
||||
|
||||
it('says nothing for an account that is not a seat', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
expect(captured.body).not.toHaveProperty('team');
|
||||
});
|
||||
|
||||
it('withholds it from an app actor', async () => {
|
||||
// Same class as the phone number: a seat's employer is not an
|
||||
// app's business.
|
||||
const { seat } = await seatOf('Acme Corp');
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{
|
||||
actor: makeActor({
|
||||
user: { uuid: seat.uuid, id: seat.id as number },
|
||||
app: { uid: 'app-1' },
|
||||
}),
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
expect(captured.body).not.toHaveProperty('team');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 401 when no actor is on the context', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -186,10 +263,10 @@ describe('whoami extension — handleWhoami', () => {
|
||||
|
||||
await runWithContext(
|
||||
{
|
||||
actor: {
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
@@ -238,10 +315,10 @@ describe('whoami extension — handleWhoami', () => {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{
|
||||
actor: {
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
@@ -269,11 +346,11 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(stored?.card_fingerprint).toBe('fp_ABC123');
|
||||
|
||||
for (const actor of [
|
||||
{ user: { uuid: user.uuid, id: user.id as number } },
|
||||
{
|
||||
makeActor({ user: { uuid: user.uuid, id: user.id as number } }),
|
||||
makeActor({
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
}),
|
||||
]) {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor }, () =>
|
||||
|
||||
+28
-17
@@ -1,6 +1,9 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { isCardFallbackEligible } from '@heyputer/backend/src/util/cardFallback.js';
|
||||
import {
|
||||
cardFallbackDepsFrom,
|
||||
isCardFallbackEligible,
|
||||
} from '@heyputer/backend/src/util/cardFallback.js';
|
||||
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
|
||||
import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js';
|
||||
import type { Request, Response } from 'express';
|
||||
@@ -107,7 +110,7 @@ export const handleWhoami = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const isUser = !actor.app;
|
||||
const isUser = !actor.effectiveApp;
|
||||
const user = await stores.user.getById(actor.user.id);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
@@ -156,6 +159,8 @@ export const handleWhoami = async (
|
||||
// every app actor. Only the verification flag ships.
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
// A seat reaches nothing until it replaces its admin's password.
|
||||
requires_password_change: user.requires_password_change,
|
||||
// The SMS-to-card escape hatch: true once this user is out of SMS send
|
||||
// attempts and may verify a card instead. It has to ship from here
|
||||
// because /send-confirm-phone can no longer say so — by the time the
|
||||
@@ -168,19 +173,7 @@ export const handleWhoami = async (
|
||||
extension.config,
|
||||
user,
|
||||
async (key) => (await stores.kv.get({ key })).res,
|
||||
{
|
||||
smsConfigured: () =>
|
||||
Boolean(clients.prelude?.isConfigured()),
|
||||
probeCardVerification: async () => {
|
||||
const status = { enabled: null as boolean | null };
|
||||
await clients.event?.emitAndWait(
|
||||
'puter.card-verification.status',
|
||||
status,
|
||||
{},
|
||||
);
|
||||
return status.enabled;
|
||||
},
|
||||
},
|
||||
cardFallbackDepsFrom(clients),
|
||||
)
|
||||
: false,
|
||||
desktop_bg_url: user.desktop_bg_url,
|
||||
@@ -251,6 +244,23 @@ export const handleWhoami = async (
|
||||
details.directories = directories;
|
||||
}
|
||||
|
||||
// The team an account belongs to, when it is one a team pays for. User
|
||||
// actors only, and only where teams are on.
|
||||
if (isUser && extension.config.teams_enabled === true) {
|
||||
try {
|
||||
const seat = await stores.team.getOrgSeat(user.id);
|
||||
if (seat) {
|
||||
details.team = {
|
||||
uid: seat.team_uid,
|
||||
name: seat.team_name ?? null,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// Never fail whoami over this; the account still works without it.
|
||||
console.warn('[whoami] team lookup failed:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// Last activity
|
||||
const lastActivityTs = toUnixSeconds(user.last_activity_ts);
|
||||
if (lastActivityTs !== undefined) {
|
||||
@@ -277,8 +287,9 @@ export const handleWhoami = async (
|
||||
delete details.referral_code;
|
||||
}
|
||||
|
||||
if (actor.app) {
|
||||
details.app_name = actor.app.uid;
|
||||
const app = actor.effectiveApp;
|
||||
if (app) {
|
||||
details.app_name = app.uid;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
Generated
+90
-635
@@ -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.12",
|
||||
"libphonenumber-js": "1.13.13",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
@@ -3268,9 +3268,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||
"version": "3.15.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
|
||||
"integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
|
||||
"version": "3.15.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
|
||||
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6055,9 +6055,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"version": "24.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
|
||||
"integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
@@ -7960,14 +7960,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
|
||||
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"destroy": "1.2.0",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
@@ -7975,6 +7976,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/debug": {
|
||||
@@ -8596,6 +8601,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -11112,10 +11127,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -11478,9 +11492,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.13.12",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.12.tgz",
|
||||
"integrity": "sha512-uLVeV1c9OTk6qkdqnj+mpMD+ZdnZ0szVyWu58HwMmpwkHA1gCEkyjd3veZQXDnuw9KEwSRjcc9B1pS9XKIN1fA==",
|
||||
"version": "1.13.13",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.13.tgz",
|
||||
"integrity": "sha512-hV6SmWnyQ8U1QXhhqbqYBHIStNnscG7A5XbGodgdHhf/8N5PwhYpJA5U/yHDjYwPYTd8uNGXqq416XctuvK2PA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
@@ -13318,19 +13332,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.22.6",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.6.tgz",
|
||||
"integrity": "sha512-fPKmeDGUzvFP7bMD5SASlJ5zIgvCC4hbanTmhbUlEmhyrY1hR4Hi3xLOWgTd3luYjLVifx6uGvMNJ2m/LlqEpg==",
|
||||
"version": "3.24.4",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
|
||||
"integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
"denque": "^2.1.0",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"long": "^5.3.2",
|
||||
"lru.min": "^1.1.4",
|
||||
"named-placeholders": "^1.1.6",
|
||||
"sql-escaper": "^1.4.0"
|
||||
"sql-escaper": "^1.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
@@ -13487,9 +13500,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.6.tgz",
|
||||
"integrity": "sha512-IQUGFdhdGwI9+AWX+FpUt4DLmvFaOjTMEoneTIWX/RXxuy1TdenPwWrvFMSfLkPKl+HQEXWuSAxEMMbPYXtBmg==",
|
||||
"version": "9.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
|
||||
"integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -14211,6 +14224,12 @@
|
||||
"node": ">= 10.12"
|
||||
}
|
||||
},
|
||||
"node_modules/postal-mime": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-3.0.0.tgz",
|
||||
"integrity": "sha512-Z4a9ar2Bv3YpK3IXag+Yda30k7bMZfpRuUGyqtHnZ2pjHG8Bl62EhZIk4n1dzv00gfzP9g+94e9kd8+XmjVWLA==",
|
||||
"license": "MIT-0"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
@@ -17174,9 +17193,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler": {
|
||||
"version": "4.124.0",
|
||||
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.124.0.tgz",
|
||||
"integrity": "sha512-75euoZKjVTJYFy+Xhctt/5JlZL4M6A4xmovZsUlep+6GHcCm14n9VtdGzNIybOW2t8wuNxRj5iMUwjT5E7Ctog==",
|
||||
"version": "4.132.0",
|
||||
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.132.0.tgz",
|
||||
"integrity": "sha512-61HD7Unw3g7h9zSQoqldzfL3MEbCKUVfwtlMgfSQtjYwMjBD8z83K/EXnYGv75R5hczx3grFfjYH+tdmJm5PHg==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -17184,10 +17203,10 @@
|
||||
"@cloudflare/unenv-preset": "2.16.1",
|
||||
"blake3-wasm": "2.1.5",
|
||||
"esbuild": "0.28.1",
|
||||
"miniflare": "5.20260815.0-alpha",
|
||||
"miniflare": "5.20260915.0-alpha",
|
||||
"path-to-regexp": "6.3.0",
|
||||
"unenv": "2.0.0-rc.24",
|
||||
"workerd": "1.20260815.1"
|
||||
"workerd": "1.20260915.1"
|
||||
},
|
||||
"bin": {
|
||||
"cf-wrangler": "bin/cf-wrangler.js",
|
||||
@@ -17201,7 +17220,7 @@
|
||||
"fsevents": "2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cloudflare/workers-types": "^5.20260815.1"
|
||||
"@cloudflare/workers-types": "^5.20260915.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cloudflare/workers-types": {
|
||||
@@ -17210,9 +17229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": {
|
||||
"version": "1.20260815.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260815.1.tgz",
|
||||
"integrity": "sha512-7PsLdcz6pT9EMd1EJGZEgMyYRfs0CHxGs62PS2L1w3s6+xGmQcRXKm/zoMftmqZF45JBa4MzFeownRKbRt/x5g==",
|
||||
"version": "1.20260915.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260915.1.tgz",
|
||||
"integrity": "sha512-EFjNxN4l5caJoHADhchROvO52EFFt+zmJvFZRmwX6UU9pE53BAbF94wRBhbpylv5Ethsd1TdJNwYuUPgZYbETg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -17227,9 +17246,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": {
|
||||
"version": "1.20260815.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260815.1.tgz",
|
||||
"integrity": "sha512-60wtg8ng7FVWeOg/UMbZ9Ye0sslpRRAKoftPbdtuH2volq676quxVr6Zm2EjVULH/JFZeCn72dbLlrnbh0Mpcw==",
|
||||
"version": "1.20260915.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260915.1.tgz",
|
||||
"integrity": "sha512-Xm2jbLw8M/o5YkuI5VsHCVU4rgnzr3ROl+Awk7FCBJ3mtLJNuxPtJNB/zZI+/t+3QtgsSPnmTisS5Gq6/KLQ/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -17244,9 +17263,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": {
|
||||
"version": "1.20260815.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260815.1.tgz",
|
||||
"integrity": "sha512-MuqKIHPo0Qyo8MZMmy0lP2B5PeAL7f4T9Fu4Usk3QdbV4JIrKG/OoybN3Ign7m/Dff+L1Oo/ZHydB+hEg1ueFw==",
|
||||
"version": "1.20260915.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260915.1.tgz",
|
||||
"integrity": "sha512-hkQp+PWBp7U6uDMd6hqUf4lfErPV79jolLaK6j4U50HrHC4jqWxESPemkxEk8H7p9jTKkZ9I9Z0nA5DLAE0J6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -17261,9 +17280,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": {
|
||||
"version": "1.20260815.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260815.1.tgz",
|
||||
"integrity": "sha512-XNFtJ5rIqJxnY6ISjkfbhT/ODiWJ6LcBvNbntuPD6I/F2k7aZeKgPaXrvWvKde66LXyzFKzc8Hn+Ydx4shevQg==",
|
||||
"version": "1.20260915.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260915.1.tgz",
|
||||
"integrity": "sha512-3yi8/irVGNQbuYTSmLXcEGupk4Js42jU3kyAx88MDbwGQUa34VbucKcNwHwfhqwEUNaIrn8ohlOEcNJChQl0Og==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -17278,9 +17297,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": {
|
||||
"version": "1.20260815.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260815.1.tgz",
|
||||
"integrity": "sha512-PiIUWrhbMg3quolwjgMvPOd75vKESjT4aDm7nL6mSjL5IOgmpO/zKstXnYfnEH3pq7sC0UCvKlF8ZPcfsh8NMw==",
|
||||
"version": "1.20260915.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260915.1.tgz",
|
||||
"integrity": "sha512-+FQ3o/grK2tQy6dw3AqSOYWsYROZqxCQKBqSHQVcXvr2x2QetCT64OrvAAJ0hpQAokREFwQyzsNmMiZbAdW0zw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -17294,525 +17313,17 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz",
|
||||
"integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz",
|
||||
"integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz",
|
||||
"integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz",
|
||||
"integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz",
|
||||
"integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz",
|
||||
"integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz",
|
||||
"integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz",
|
||||
"integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz",
|
||||
"integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz",
|
||||
"integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz",
|
||||
"integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz",
|
||||
"integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz",
|
||||
"integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz",
|
||||
"integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz",
|
||||
"integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/miniflare": {
|
||||
"version": "5.20260815.0-alpha",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260815.0-alpha.tgz",
|
||||
"integrity": "sha512-YAaGj4Sh5f4fqHKiMQ8zRHDOOM5IGUVtMhnLIeyjuQfU+9P6hcOTrHUVtbfj/ZPay9Kzik4pWELB39pGgefjiQ==",
|
||||
"version": "5.20260915.0-alpha",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260915.0-alpha.tgz",
|
||||
"integrity": "sha512-ApLsq9X2jbpkLAPXx89afGvuNxAFzd1rUK+nzfBSLu+34YWc2Q2YlgH6f3kSEEQYZ2gAlC5Q5IP6tDNNg/1KqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "0.8.1",
|
||||
"sharp": "0.35.2",
|
||||
"sharp": "0.35.4",
|
||||
"undici": "7.29.0",
|
||||
"workerd": "1.20260815.1",
|
||||
"workerd": "1.20260915.1",
|
||||
"ws": "8.21.0",
|
||||
"youch": "4.1.0-beta.10"
|
||||
},
|
||||
@@ -17827,55 +17338,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrangler/node_modules/sharp": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
|
||||
"integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
|
||||
"node_modules/wrangler/node_modules/undici": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.8.4"
|
||||
},
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.2",
|
||||
"@img/sharp-darwin-x64": "0.35.2",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.2",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.1",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.1",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.1",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.1",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.1",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.1",
|
||||
"@img/sharp-linux-arm": "0.35.2",
|
||||
"@img/sharp-linux-arm64": "0.35.2",
|
||||
"@img/sharp-linux-ppc64": "0.35.2",
|
||||
"@img/sharp-linux-riscv64": "0.35.2",
|
||||
"@img/sharp-linux-s390x": "0.35.2",
|
||||
"@img/sharp-linux-x64": "0.35.2",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.2",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.2",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.2",
|
||||
"@img/sharp-win32-arm64": "0.35.2",
|
||||
"@img/sharp-win32-ia32": "0.35.2",
|
||||
"@img/sharp-win32-x64": "0.35.2"
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/workerd": {
|
||||
"version": "1.20260815.1",
|
||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260815.1.tgz",
|
||||
"integrity": "sha512-8bArFkHmlp7qFEKVPyNzDzHzS35gc2fg0PYBcDtaNLF7UCDryCX2BQnpkUkTHYIy824IRrHOTwOEoTj0sUO2Fg==",
|
||||
"version": "1.20260915.1",
|
||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260915.1.tgz",
|
||||
"integrity": "sha512-9Nqh22lW/ondXnMkFTbn65MHZLdsE2izJ9sFIVdqAufQTEVWnUkcpp7T5JtLr4ASN/VuxlTVZ5xgYntyo+d4ag==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
@@ -17886,11 +17362,11 @@
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@cloudflare/workerd-darwin-64": "1.20260815.1",
|
||||
"@cloudflare/workerd-darwin-arm64": "1.20260815.1",
|
||||
"@cloudflare/workerd-linux-64": "1.20260815.1",
|
||||
"@cloudflare/workerd-linux-arm64": "1.20260815.1",
|
||||
"@cloudflare/workerd-windows-64": "1.20260815.1"
|
||||
"@cloudflare/workerd-darwin-64": "1.20260915.1",
|
||||
"@cloudflare/workerd-darwin-arm64": "1.20260915.1",
|
||||
"@cloudflare/workerd-linux-64": "1.20260915.1",
|
||||
"@cloudflare/workerd-linux-arm64": "1.20260915.1",
|
||||
"@cloudflare/workerd-windows-64": "1.20260915.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
@@ -18152,7 +17628,7 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.6.0",
|
||||
"busboy": "^1.6.0",
|
||||
"compression": "^1.8.1",
|
||||
"compression": "^1.8.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dedent": "^1.5.3",
|
||||
"dynalite": "^4.0.0",
|
||||
@@ -18170,7 +17646,7 @@
|
||||
"mime-types": "^2.1.35",
|
||||
"murmurhash": "^2.0.1",
|
||||
"mysql2": "^3.22.6",
|
||||
"nodemailer": "^9.0.6",
|
||||
"nodemailer": "^9.1.1",
|
||||
"openai": "^6.34.0",
|
||||
"otpauth": "^9.5.2",
|
||||
"pg": "^8.21.0",
|
||||
@@ -18191,7 +17667,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/node": "^24.13.4",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.6.1",
|
||||
"@types/validator": "^13.15.10",
|
||||
@@ -18806,28 +18282,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"src/docs/node_modules/js-yaml": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"src/docs/node_modules/jsdom": {
|
||||
"version": "26.1.0",
|
||||
"license": "MIT",
|
||||
@@ -18983,12 +18437,13 @@
|
||||
},
|
||||
"src/puter-js": {
|
||||
"name": "@heyputer/puter.js",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@heyputer/kv.js": "^0.2.1",
|
||||
"open": "^10.2.0",
|
||||
"path-browserify": "1.0.1",
|
||||
"postal-mime": "^3.0.0",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.12",
|
||||
"libphonenumber-js": "1.13.13",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
|
||||
@@ -17,24 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Optional ClickHouse client.
|
||||
*
|
||||
* ClickHouse is NOT a hard dependency. Core Puter runs without it — the app
|
||||
* stats read path (open / unique-user counts) falls back to the primary SQL
|
||||
* database. `this.clients.clickhouse` is therefore typed as optional and is
|
||||
* `undefined` in default and self-hosted setups; callers MUST branch on its
|
||||
* presence and fall back to SQL.
|
||||
*
|
||||
* A production deployment can register a real ClickHouse client via an
|
||||
* extension (`extension.registerClient('clickhouse', client)`) to offload the
|
||||
* analytics queries off the primary database and keep the stats path fast at
|
||||
* scale. When registered, the instance flows into `this.clients.clickhouse`
|
||||
* everywhere it's typed.
|
||||
*
|
||||
* Only the surface the stats path actually consumes is declared here. Extend
|
||||
* this interface (don't widen to `any`) when a new query shape is needed.
|
||||
*/
|
||||
// Optional analytics client, registered by an extension. Only the surface the
|
||||
// app-stats path consumes is declared; extend it rather than widening to `any`.
|
||||
|
||||
export interface ClickhouseQueryResult {
|
||||
json<T = Record<string, unknown>>(): Promise<T[]>;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import { createPool, type Pool } from 'mysql2';
|
||||
import { createPool, ExecuteValues, type Pool } from 'mysql2';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
import { SQLBatcher } from './SQLBatcher.js';
|
||||
@@ -54,6 +54,8 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
private replicaPool!: Pool;
|
||||
private db!: SQLBatcher;
|
||||
private dbReplica!: SQLBatcher;
|
||||
/** Primary pool, SELECT-only: same rows as `db`, without the transaction. */
|
||||
private dbPrimaryRead!: SQLBatcher;
|
||||
private configuration = Configuration.SINGLE;
|
||||
private shutdownStarted = false;
|
||||
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -79,6 +81,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
console.log('[mysql] connected to primary');
|
||||
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
this.dbPrimaryRead = this.createPrimaryReadBatcher(this.primaryPool);
|
||||
|
||||
if (dbConf.replica) {
|
||||
this.replicaPool = this.createPool(dbConf.replica);
|
||||
@@ -162,7 +165,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.db.execute(query, params);
|
||||
const result = await this.dbPrimaryRead.execute(query, params);
|
||||
if (!result) return [];
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
@@ -201,7 +204,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
await conn.beginTransaction();
|
||||
try {
|
||||
for (const { statement, values } of entries) {
|
||||
await conn.execute(statement, values);
|
||||
await conn.execute(statement, values as ExecuteValues);
|
||||
}
|
||||
await conn.commit();
|
||||
} catch (err) {
|
||||
@@ -224,7 +227,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
// Run both reads in parallel — prefer replica when it returns rows,
|
||||
// otherwise fall back to primary to handle replication lag.
|
||||
const primaryPromise = this.db.execute(query, params);
|
||||
const primaryPromise = this.dbPrimaryRead.execute(query, params);
|
||||
try {
|
||||
const replicaResult = await this.dbReplica.execute(query, params);
|
||||
if (
|
||||
@@ -354,6 +357,22 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads that must see the primary still only read, so they skip the
|
||||
* batcher's transaction wrapper. Worth a separate batcher because BEGIN and
|
||||
* COMMIT are round trips: against a primary in another region they cost
|
||||
* more than the query.
|
||||
*/
|
||||
private createPrimaryReadBatcher(pool: Pool): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 30,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'primary',
|
||||
readOnly: true,
|
||||
acquireTimeoutMs: this.config.database?.acquireTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
private createReplicaBatcher(pool: Pool): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 10,
|
||||
@@ -378,6 +397,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
database: dbConf.database ?? 'puter',
|
||||
});
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
this.dbPrimaryRead = this.createPrimaryReadBatcher(this.primaryPool);
|
||||
|
||||
if (this.configuration === Configuration.SINGLE) {
|
||||
this.replicaPool = this.primaryPool;
|
||||
|
||||
@@ -284,11 +284,16 @@ export class SQLBatcher {
|
||||
// individually below. Without this, MySQL would commit every
|
||||
// statement up to the failure point and a per-item retry would
|
||||
// misreport already-committed inserts as duplicate-key failures.
|
||||
//
|
||||
// A read-only batch has nothing to roll back, and the wrapper is not
|
||||
// free: BEGIN and COMMIT are round trips of their own, which triples
|
||||
// the cost of a read against a pool in another region.
|
||||
const wrapInTransaction = !this.readOnly;
|
||||
let batchSucceeded = false;
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
if (wrapInTransaction) await connection.beginTransaction();
|
||||
const [results, fields] = await connection.query(query, values);
|
||||
await connection.commit();
|
||||
if (wrapInTransaction) await connection.commit();
|
||||
batchSucceeded = true;
|
||||
this.#consecutiveFailures = 0;
|
||||
for (let i = 0; i < batch.length; i++) {
|
||||
@@ -296,10 +301,12 @@ export class SQLBatcher {
|
||||
b.resolve([results[i], fields?.[i]]);
|
||||
}
|
||||
} catch (batchError) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (rollbackError) {
|
||||
console.warn('SQLBatcher rollback failed:', rollbackError);
|
||||
if (wrapInTransaction) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (rollbackError) {
|
||||
console.warn('SQLBatcher rollback failed:', rollbackError);
|
||||
}
|
||||
}
|
||||
console.warn(
|
||||
'SQLBatcher batch failed; retrying items individually:',
|
||||
@@ -311,10 +318,11 @@ export class SQLBatcher {
|
||||
|
||||
if (batchSucceeded) return;
|
||||
|
||||
// Per-item fallback. The transaction was rolled back so no statement
|
||||
// committed; re-running each item independently produces clean
|
||||
// success/failure outcomes for each caller. Concurrency is capped to
|
||||
// avoid briefly saturating the pool when a large batch fails.
|
||||
// Per-item fallback. Nothing from the batch is live — it was rolled
|
||||
// back, or carried only SELECTs — so re-running each item
|
||||
// independently produces clean success/failure outcomes for each
|
||||
// caller. Concurrency is capped to avoid briefly saturating the pool
|
||||
// when a large batch fails.
|
||||
flushFailureCounter.add(1, this.#metricAttrs);
|
||||
fallbackInvocationsCounter.add(1, this.#metricAttrs);
|
||||
|
||||
|
||||
@@ -86,6 +86,45 @@ describe('SQLBatcher', () => {
|
||||
expect(conn.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips the transaction wrapper on a readOnly batcher', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
batcher.query('SELECT a', []),
|
||||
batcher.query('SELECT b', []),
|
||||
]);
|
||||
expect(a[0]).toEqual([{ n: 0 }]);
|
||||
expect(b[0]).toEqual([{ n: 1 }]);
|
||||
// BEGIN and COMMIT are round trips; a SELECT-only batch has nothing to
|
||||
// roll back, so it must not pay for them.
|
||||
expect(conn.beginTransaction).not.toHaveBeenCalled();
|
||||
expect(conn.commit).not.toHaveBeenCalled();
|
||||
expect(conn.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not roll back a failed readOnly batch', async () => {
|
||||
const conn = makeConnection((sql: string) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ER_LOCK_DEADLOCK');
|
||||
return [[{ n: 0 }], undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
batcher.query('SELECT a', []),
|
||||
batcher.query('SELECT b', []),
|
||||
]);
|
||||
expect(conn.rollback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops the oldest item with reason queueOverflow at the high-water mark', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool } = makePool(conn);
|
||||
|
||||
@@ -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 = 79;
|
||||
const CURRENT_SCHEMA_VERSION = 81;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
|
||||
@@ -113,6 +113,8 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[76, ['0081_event-subscriptions-indexes.sql']],
|
||||
[77, ['0082_temp-password-expiry.sql']],
|
||||
[78, ['0083_team-directory.sql']],
|
||||
[79, ['0084_share-anyone-with-link.sql']],
|
||||
[80, ['0085_event-subscriptions-include-value.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- 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/>.
|
||||
|
||||
-- See sqlite/0084_share-anyone-with-link.sql for the column rationale.
|
||||
-- No per-file applied-state tracking, so the column goes through _puter_add_col
|
||||
-- and the index through a guarded procedure, as mysql_mig_34 does.
|
||||
|
||||
CALL _puter_add_col('share', 'anyone', '`anyone` tinyint(1) DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_share_anyone_index;
|
||||
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_share_anyone_index()
|
||||
BEGIN
|
||||
-- Every other row leaves `anyone` NULL, so this binds only link shares.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND INDEX_NAME = 'idx_share_anyone'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD UNIQUE INDEX `idx_share_anyone` (`fsentry_id`, `anyone`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_share_anyone_index();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_share_anyone_index;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 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/>.
|
||||
|
||||
-- See sqlite/0085_event-subscriptions-include-value.sql for the column
|
||||
-- rationale. No per-file applied-state tracking, so it goes through
|
||||
-- _puter_add_col as mysql_mig_39 does.
|
||||
|
||||
CALL _puter_add_col('event_subscriptions', 'include_value', '`include_value` tinyint(1) NOT NULL DEFAULT 0');
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 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/>.
|
||||
|
||||
-- See sqlite/0084_share-anyone-with-link.sql for the column rationale.
|
||||
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
|
||||
|
||||
ALTER TABLE share ADD COLUMN IF NOT EXISTS anyone smallint;
|
||||
|
||||
-- Default NULLS DISTINCT, matching the other engines, so this binds only link shares.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_share_anyone ON share (fsentry_id, anyone);
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 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/>.
|
||||
|
||||
-- See sqlite/0085_event-subscriptions-include-value.sql for the column
|
||||
-- rationale. Idempotent via IF NOT EXISTS; there is no per-file applied-state
|
||||
-- tracking.
|
||||
|
||||
ALTER TABLE event_subscriptions ADD COLUMN IF NOT EXISTS include_value smallint NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 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/>.
|
||||
|
||||
-- "Anyone with the link": a share row with no holder of any kind — not a
|
||||
-- person, not a team, not an invite. `anyone` is 1 on such a row and NULL on
|
||||
-- every other, so the unique index binds only these: one per node, whoever
|
||||
-- last set it. No permission row stands behind it; ACLService reads the share
|
||||
-- row itself, and honours it only while the owner's plan covers link sharing.
|
||||
|
||||
ALTER TABLE `share` ADD COLUMN `anyone` INTEGER DEFAULT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_share_anyone`
|
||||
ON `share` (`fsentry_id`, `anyone`);
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
-- 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 key-value subscription may ask for the key's new value to ride along with
|
||||
-- each delivery. Off by default: a delivery that carries nothing but the key is
|
||||
-- what every existing row was made under.
|
||||
|
||||
ALTER TABLE `event_subscriptions`
|
||||
ADD COLUMN `include_value` INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -39,6 +39,9 @@ export interface EmailAttachment {
|
||||
path?: string;
|
||||
contentType?: string;
|
||||
encoding?: string;
|
||||
/** Content-ID of an inline part the html body references as `cid:`. */
|
||||
cid?: string;
|
||||
contentDisposition?: 'attachment' | 'inline';
|
||||
}
|
||||
|
||||
/** Subset of the transport's send result callers may care about. */
|
||||
|
||||
@@ -351,9 +351,11 @@ [email protected] immediately.
|
||||
subject: 'Your {{team_name}} account on Puter',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>{{team_name}} has created a Puter account for you: <b>{{username}}</b>.
|
||||
They will send you a temporary password separately; you will be asked to
|
||||
choose your own the first time you sign in.</p>
|
||||
<p>{{team_name}} has created a Puter account for you.</p>
|
||||
<p>Username: <b>{{username}}</b><br>
|
||||
Temporary password: <b>{{temporary_password}}</b></p>
|
||||
<p>You will be asked to choose your own password the first time you sign in.
|
||||
This temporary one stops working then, and it expires on its own if unused.</p>
|
||||
<p>What this means:</p>
|
||||
<ul>
|
||||
<li>This account belongs to {{team_name}}. They pay for it and can close it.</li>
|
||||
|
||||
@@ -54,19 +54,9 @@ export type TeamBillingEvent = TeamBillingContext & {
|
||||
};
|
||||
|
||||
/**
|
||||
* Extension-augmentable half of {@link EventMap}. Extensions that emit their own
|
||||
* events declare the payload here by declaration merging, so both the emitter
|
||||
* and every listener are typed against the same shape:
|
||||
*
|
||||
* declare module '@heyputer/backend/clients/event/types' {
|
||||
* interface IExtensionEventMap {
|
||||
* 'my.thing.happened': { thingId: string };
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Deliberately member-less and index-signature-free: an index signature here
|
||||
* would widen `keyof EventMap` to `string` and silently disable key checking on
|
||||
* every `emit` in the tree.
|
||||
* Extension-augmentable half of {@link EventMap}, declaration-merged like
|
||||
* `IExtensionClientInstances`. No index signature: it would widen `keyof
|
||||
* EventMap` to `string` and disable key checking on every `emit`.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface IExtensionEventMap {}
|
||||
@@ -294,6 +284,7 @@ export type EventMap = {
|
||||
'puter.card-verification.setup': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
email?: string;
|
||||
ip?: string | null;
|
||||
// Client-supplied device fingerprint for this request, or null. Lets
|
||||
// the abuse extension cap card-verification setups per device (across
|
||||
@@ -320,6 +311,7 @@ export type EventMap = {
|
||||
'puter.card-verification.confirm': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
email?: string;
|
||||
setup_intent_id: string;
|
||||
enabled: boolean | null;
|
||||
verified: boolean;
|
||||
@@ -431,6 +423,12 @@ export type EventMap = {
|
||||
userId: number;
|
||||
keys: string[];
|
||||
op: KvOp;
|
||||
/**
|
||||
* What each key holds after the change, aligned with `keys`: the
|
||||
* written value on a `set`, `null` on a `del`. Absent when the write
|
||||
* did not have it in hand, as an `expire` does not.
|
||||
*/
|
||||
values?: unknown[];
|
||||
};
|
||||
/**
|
||||
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
|
||||
@@ -695,6 +693,11 @@ export type EventMap = {
|
||||
// normalized path: `route.<method>.<path>.before|after|error|reject`. Same
|
||||
// wildcard + veto semantics as the driver lifecycle above.
|
||||
[K in `route.${string}`]: RouteLifecycleEvent;
|
||||
} & {
|
||||
// Cost factor for recorded AI usage, keyed by driver and model:
|
||||
// `ai.cost.factor.<driverName>.<provider>:<model>`. Emitted once per model
|
||||
// per batch; the last listener to set `factor` wins.
|
||||
[K in `ai.cost.factor.${string}`]: AiCostFactorEvent;
|
||||
} & {
|
||||
[K in `pubsub.login.${string}`]: { authtoken: string };
|
||||
} & {
|
||||
@@ -715,6 +718,18 @@ export type EventMap = {
|
||||
'outer.pubsub.metering.credits-changed': { userUuid: string };
|
||||
} & IExtensionEventMap;
|
||||
|
||||
/** Payload for `ai.cost.factor.<driver>.<model>` events. */
|
||||
export type AiCostFactorEvent = {
|
||||
/** Driver doing the pricing, e.g. `ai-chat`. */
|
||||
driver: string;
|
||||
/** `<provider>:<model>` the usage is recorded under. */
|
||||
model: string;
|
||||
/** Who the usage is being charged to. */
|
||||
actor: Actor;
|
||||
/** Applied to the cost, starting at 1. Values <= 0 are ignored. */
|
||||
factor: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Phase of a request/method lifecycle. `reject` is emitted when a `before`
|
||||
* listener vetoes the call (sets `allow = false`); the call never runs and no
|
||||
@@ -793,10 +808,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>;
|
||||
|
||||
@@ -140,13 +140,9 @@ export interface EventsInvokeCall {
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an invocation is delivered.
|
||||
*
|
||||
* `status` is what the handler answered with. A `null` status means nothing
|
||||
* ran: no transport, no script, or a dispatcher that refused us — all of which
|
||||
* are the platform's fault and so retriable. `handled` is set only when the
|
||||
* answer is provably the script's own (the handled header); a 4xx without it
|
||||
* came from something between here and the script, not a handler's refusal.
|
||||
* Delivers an invocation. `status` null means nothing ran (retriable);
|
||||
* `handled` is set only when the answer provably came from the script, so an
|
||||
* unmarked 4xx is not a handler's refusal.
|
||||
*/
|
||||
export interface EventsInvokeTransport {
|
||||
send(call: EventsInvokeCall): Promise<{
|
||||
@@ -159,14 +155,9 @@ export interface EventsInvokeTransport {
|
||||
}
|
||||
|
||||
/**
|
||||
* What the call did.
|
||||
*
|
||||
* - `settled` — the handler took the delivery (2xx).
|
||||
* - `terminal` — it refused it (a marked 4xx). Retrying sends the same body to
|
||||
* the same code, so it is not retried.
|
||||
* - `retriable` — it could not answer: 5xx, 429, a timeout, a transport failure,
|
||||
* an unmarked 4xx (nothing said it was a handler's answer), or no way to
|
||||
* reach the worker at all.
|
||||
* `settled`: handler accepted (2xx). `terminal`: handler refused (marked 4xx),
|
||||
* not retried. `retriable`: no handler answer (5xx, 429, timeout, transport
|
||||
* failure, unmarked 4xx).
|
||||
*/
|
||||
export type WorkerInvokeOutcome = 'settled' | 'terminal' | 'retriable';
|
||||
|
||||
|
||||
@@ -107,14 +107,8 @@ const buildCluster = (config: IConfig): Cluster => {
|
||||
};
|
||||
|
||||
/**
|
||||
* `RedisClient` IS the ioredis `Cluster` instance — consumers call
|
||||
* `this.clients.redis.get(...)` / `.set(...)` directly rather than going
|
||||
* through an inner `.client` field. Lifecycle methods (`onServerShutdown`) are
|
||||
* attached onto the cluster instance itself.
|
||||
*
|
||||
* Type-wise, `RedisClient` is `Cluster & WithLifecycle`; the registry- facing
|
||||
* value below is a constructor that returns that shape. Mirrors the
|
||||
* `DatabaseClientFactory` pattern.
|
||||
* The ioredis `Cluster` itself, with lifecycle methods attached; the
|
||||
* constructor below returns it.
|
||||
*/
|
||||
export type RedisClient = Cluster & WithLifecycle;
|
||||
|
||||
|
||||
@@ -21,32 +21,19 @@ import type { IConfig, WithLifecycle } from '../types';
|
||||
import type { ClickhouseClient } from './clickhouse/ClickhouseClient';
|
||||
|
||||
/**
|
||||
* Extension-augmentable client registry. Extensions add their own client
|
||||
* instance types via TypeScript declaration merging:
|
||||
*
|
||||
* declare module '@heyputer/backend/clients/types' {
|
||||
* interface IExtensionClientInstances {
|
||||
* myClient: MyClient;
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Augmentations flow into `this.clients` everywhere it's typed (PuterStore,
|
||||
* PuterService, PuterController, PuterDriver) and into the
|
||||
* `extension.import('client')` proxy.
|
||||
* Extension-augmentable client registry. Extensions add typed keys by
|
||||
* declaration-merging this interface from `@heyputer/backend/clients/types`;
|
||||
* the result is what `this.clients` and `extension.import('client')` see. The
|
||||
* same pattern applies to the store, service, driver and controller
|
||||
* registries.
|
||||
*/
|
||||
export interface IExtensionClientInstances {
|
||||
/**
|
||||
* Open index signature so reads of extension-only client keys return
|
||||
* `unknown` instead of a type error. Concrete declaration-merged keys
|
||||
* override this for that name.
|
||||
*/
|
||||
/** Unmerged extension keys read as `unknown` rather than erroring. */
|
||||
[key: string]: unknown;
|
||||
|
||||
/**
|
||||
* Optional ClickHouse analytics client. Absent by default — a production
|
||||
* deployment registers it via an extension to speed up the app-stats path
|
||||
* at scale (see {@link ClickhouseClient}). Always branch on its presence and
|
||||
* fall back to SQL.
|
||||
* Registered by an extension; absent by default, so branch on it and fall
|
||||
* back to SQL.
|
||||
*/
|
||||
clickhouse?: ClickhouseClient;
|
||||
}
|
||||
|
||||
@@ -186,14 +186,13 @@ export class AppController extends PuterController {
|
||||
|
||||
// POST /rao — record a recent app open. When an app-under-user
|
||||
// actor calls this, the app id is already on the token — clients
|
||||
// don't re-send it in the body. Fall back to `actor.app.uid`
|
||||
// don't re-send it in the body. Fall back to the acting app's uid
|
||||
// before 400-ing for a missing body field.
|
||||
//
|
||||
// Authorization: only two callers are trusted to report opens —
|
||||
// 1. a root user actor (plain session, no `.app` and no access
|
||||
// token), e.g. the GUI launching apps on behalf of the user;
|
||||
// 2. the app-under-user actor for the app being reported, i.e.
|
||||
// `actor.app.uid === app_uid`.
|
||||
// 2. the app-under-user actor for the app being reported.
|
||||
// Everything else — access tokens (regardless of issuer), asset
|
||||
// tokens, app actors reporting for a *different* app — is denied,
|
||||
// otherwise any authenticated party could inflate another app's
|
||||
@@ -208,11 +207,11 @@ export class AppController extends PuterController {
|
||||
async (req, res) => {
|
||||
const actor = req.actor;
|
||||
const bodyAppUid = req.body?.app_uid;
|
||||
const actorAppUid = actor?.app?.uid;
|
||||
const callerAppUid = actor?.effectiveApp?.uid;
|
||||
const app_uid =
|
||||
typeof bodyAppUid === 'string' && bodyAppUid.length > 0
|
||||
? bodyAppUid
|
||||
: actorAppUid;
|
||||
: callerAppUid;
|
||||
if (!app_uid || typeof app_uid !== 'string') {
|
||||
throw new HttpError(400, 'Missing or invalid `app_uid`', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -231,7 +230,7 @@ export class AppController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
if (isAppActor(actor) && app_uid !== actorAppUid) {
|
||||
if (isAppActor(actor) && app_uid !== callerAppUid) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'App actors can only report opens for their own app',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
@@ -411,10 +411,10 @@ describe('AppController POST /rao', () => {
|
||||
const app = await createApp(owner.actor);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
const actorWithApp: unknown = {
|
||||
const actorWithApp: unknown = makeActor({
|
||||
...owner.actor,
|
||||
app: { uid: app.uid },
|
||||
};
|
||||
});
|
||||
await withActor(owner.actor, () =>
|
||||
callRoute(
|
||||
'post',
|
||||
@@ -901,10 +901,10 @@ describe('AppController POST /rao actor gating', () => {
|
||||
const { actor } = await makeUser();
|
||||
const own = await createApp(actor);
|
||||
const other = await createApp(actor);
|
||||
const appActor = {
|
||||
const appActor = makeActor({
|
||||
...actor,
|
||||
app: { uid: own.uid as string },
|
||||
} as unknown as Actor;
|
||||
} as unknown as Actor);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
|
||||
@@ -33,7 +33,7 @@ import jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import type { EventClient } from '../../clients/event/EventClient.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { Context, runWithContext } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { requireUserActorGate } from '../../core/http/middleware/gates.js';
|
||||
@@ -393,8 +393,7 @@ describe('concurrent claims on one email address', () => {
|
||||
expect(await countOwners(email)).toBe(1);
|
||||
|
||||
const rejected = results.find((r) => r.status === 'rejected') as
|
||||
| PromiseRejectedResult
|
||||
| undefined;
|
||||
PromiseRejectedResult | undefined;
|
||||
expect(rejected?.reason).toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
@@ -2357,10 +2356,10 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
);
|
||||
appUid = app.uid;
|
||||
appId = app.id;
|
||||
appActor = {
|
||||
appActor = makeActor({
|
||||
user: userActor.user,
|
||||
app: { id: app.id, uid: app.uid },
|
||||
} as unknown as Actor;
|
||||
});
|
||||
});
|
||||
|
||||
const grant = (body: Record<string, unknown>) =>
|
||||
@@ -2404,11 +2403,11 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
const path = (name: string) => `/${user.username}/${name}`;
|
||||
const rand = () => uuidv4().slice(0, 6);
|
||||
|
||||
it('creates a missing directory, grants it, and a subsequent check reports it held', async () => {
|
||||
it('creates a missing directory by default, grants it, and a subsequent check reports it held', async () => {
|
||||
const p = path(`.mail-${rand()}`);
|
||||
expect(await server.stores.fsEntry.getEntryByPath(p)).toBeFalsy();
|
||||
|
||||
await grant({ permission: `fs:${p}:write`, create: true });
|
||||
await grant({ permission: `fs:${p}:write` });
|
||||
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(p);
|
||||
expect(entry?.isDir).toBe(true);
|
||||
@@ -2454,10 +2453,10 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('without `create`, a missing path still 404s and nothing is created', async () => {
|
||||
it('`create: false` opts out: a missing path 404s and nothing is created', async () => {
|
||||
const p = path(`missing-${rand()}`);
|
||||
await expect(
|
||||
grant({ permission: `fs:${p}:write` }),
|
||||
grant({ permission: `fs:${p}:write`, create: false }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
@@ -2476,7 +2475,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
expect(parentEntry?.isDir).toBe(true);
|
||||
const leafEntry = await server.stores.fsEntry.getEntryByPath(leaf);
|
||||
expect(leafEntry?.isDir).toBe(true);
|
||||
expect(await grantedPermissions()).toContain(`fs:${leafEntry!.uuid}:write`);
|
||||
expect(await grantedPermissions()).toContain(
|
||||
`fs:${leafEntry!.uuid}:write`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back only the leaf, not auto-created intermediate directories, when a later phase fails', async () => {
|
||||
@@ -2525,18 +2526,26 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
await expect(
|
||||
grant({ permission: `fs:${appDataPath}:write`, create: true }),
|
||||
).rejects.toMatchObject({ statusCode: 403, legacyCode: 'forbidden' });
|
||||
expect(await server.stores.fsEntry.getEntryByPath(appDataPath)).toBeFalsy();
|
||||
expect(await server.stores.fsEntry.getEntryByPath(appDataSubdir)).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(appDataPath),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(appDataSubdir),
|
||||
).toBeFalsy();
|
||||
|
||||
const trashPath = path(`Trash/x-${rand()}`);
|
||||
await expect(
|
||||
grant({ permission: `fs:${trashPath}:write`, create: true }),
|
||||
).rejects.toMatchObject({ statusCode: 403, legacyCode: 'forbidden' });
|
||||
expect(await server.stores.fsEntry.getEntryByPath(trashPath)).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(trashPath),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('rejects a path more than the depth limit below home, creating nothing', async () => {
|
||||
const deep = path(Array.from({ length: 17 }, () => `d-${rand()}`).join('/'));
|
||||
const deep = path(
|
||||
Array.from({ length: 17 }, () => `d-${rand()}`).join('/'),
|
||||
);
|
||||
await expect(
|
||||
grant({ permission: `fs:${deep}:write`, create: true }),
|
||||
).rejects.toMatchObject({
|
||||
@@ -2624,7 +2633,7 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
it('does not apply `create` to a `manage:` grant — 404 unchanged, nothing created', async () => {
|
||||
const p = path(`manage-missing-${rand()}`);
|
||||
await expect(
|
||||
grant({ permission: `manage:fs:${p}:write`, create: true }),
|
||||
grant({ permission: `manage:fs:${p}:write` }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
@@ -2639,24 +2648,33 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
createMissingParents: true,
|
||||
});
|
||||
const missingPath = path(`.newmail-${rand()}`);
|
||||
expect(await server.stores.fsEntry.getEntryByPath(missingPath)).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(missingPath),
|
||||
).toBeFalsy();
|
||||
|
||||
const mkdirSpy = vi.spyOn(server.services.fs, 'mkdir');
|
||||
const touchSpy = vi.spyOn(server.services.fs, 'touch');
|
||||
try {
|
||||
await grant({
|
||||
permissions: [`fs:${existingPath}:write`, `fs:${missingPath}:write`],
|
||||
permissions: [
|
||||
`fs:${existingPath}:write`,
|
||||
`fs:${missingPath}:write`,
|
||||
],
|
||||
create: true,
|
||||
});
|
||||
// Only the missing one is actually created.
|
||||
expect(mkdirSpy.mock.calls.length + touchSpy.mock.calls.length).toBe(1);
|
||||
expect(
|
||||
mkdirSpy.mock.calls.length + touchSpy.mock.calls.length,
|
||||
).toBe(1);
|
||||
} finally {
|
||||
mkdirSpy.mockRestore();
|
||||
touchSpy.mockRestore();
|
||||
}
|
||||
|
||||
const existingEntry = await server.stores.fsEntry.getEntryByPath(existingPath);
|
||||
const createdEntry = await server.stores.fsEntry.getEntryByPath(missingPath);
|
||||
const existingEntry =
|
||||
await server.stores.fsEntry.getEntryByPath(existingPath);
|
||||
const createdEntry =
|
||||
await server.stores.fsEntry.getEntryByPath(missingPath);
|
||||
expect(createdEntry?.isDir).toBe(true);
|
||||
const perms = await grantedPermissions();
|
||||
expect(perms).toContain(`fs:${existingEntry!.uuid}:write`);
|
||||
@@ -2673,7 +2691,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
permissions: [`fs:${p}:write`, `fs:${p}:read`],
|
||||
create: true,
|
||||
});
|
||||
expect(mkdirSpy.mock.calls.length + touchSpy.mock.calls.length).toBe(1);
|
||||
expect(
|
||||
mkdirSpy.mock.calls.length + touchSpy.mock.calls.length,
|
||||
).toBe(1);
|
||||
} finally {
|
||||
mkdirSpy.mockRestore();
|
||||
touchSpy.mockRestore();
|
||||
@@ -2694,7 +2714,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
expect(await server.stores.fsEntry.getEntryByPath(goodPath)).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(goodPath),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('rolls back what phase B created when phase C fails afterward', async () => {
|
||||
@@ -2726,7 +2748,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
|
||||
// Phase B created the directory; phase C then failed on the second
|
||||
// entry, and the rollback undid it.
|
||||
expect(await server.stores.fsEntry.getEntryByPath(missingPath)).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(missingPath),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('grant/revoke parity: revoking after a `create` grant removes the row and check no longer holds it', async () => {
|
||||
@@ -2738,7 +2762,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
expect(await checkAsApp(`fs:${p}:write`)).toBe(false);
|
||||
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(p);
|
||||
expect(await grantedPermissions()).not.toContain(`fs:${entry!.uuid}:write`);
|
||||
expect(await grantedPermissions()).not.toContain(
|
||||
`fs:${entry!.uuid}:write`,
|
||||
);
|
||||
});
|
||||
|
||||
it('check-permissions on a missing fs path answers false rather than 404ing, without poisoning a mixed batch', async () => {
|
||||
@@ -2754,7 +2780,12 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
await inCtx(appActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{ permissions: [`fs:${missingPath}:write`, heldPermission] },
|
||||
{
|
||||
permissions: [
|
||||
`fs:${missingPath}:write`,
|
||||
heldPermission,
|
||||
],
|
||||
},
|
||||
{ actor: appActor },
|
||||
),
|
||||
res,
|
||||
@@ -2764,7 +2795,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
const body = res.body as { permissions: Record<string, boolean> };
|
||||
expect(body.permissions[`fs:${missingPath}:write`]).toBe(false);
|
||||
expect(body.permissions[heldPermission]).toBe(true);
|
||||
expect(await server.stores.fsEntry.getEntryByPath(missingPath)).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(missingPath),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('ignores `create` on a non-fs permission and grants it normally', async () => {
|
||||
@@ -2777,7 +2810,10 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
for (const badCreate of ['sock', 1, {}]) {
|
||||
const p = path(`.badcreate-${rand()}`);
|
||||
await expect(
|
||||
grant({ permission: `fs:${p}:write`, create: badCreate as never }),
|
||||
grant({
|
||||
permission: `fs:${p}:write`,
|
||||
create: badCreate as never,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
@@ -3879,8 +3915,8 @@ describe('AuthController.handleConfirmPhone', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('short-circuits to verified when the gate is not set (no Prelude call)', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
it('short-circuits to verified when a verified number is on file (no Prelude call)', async () => {
|
||||
const { actor } = await makeUserAndActor({ phone: '+14155550123' });
|
||||
const checkVerification = vi.fn();
|
||||
await withPrelude(stubPrelude({ checkVerification }), async () => {
|
||||
const res = makeRes();
|
||||
@@ -3893,6 +3929,34 @@ describe('AuthController.handleConfirmPhone', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('verifies for real when the gate was never set but no number is on file', async () => {
|
||||
// A route requiring a verified phone sends never-flagged users here;
|
||||
// answering "verified" on the clear flag alone would store nothing and
|
||||
// leave that route refusing them.
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
await server.stores.kv.set({
|
||||
key: `phone-verify-pending:${user.id}`,
|
||||
value: '+14155550123',
|
||||
});
|
||||
const checkVerification = vi.fn(async () => ({ status: 'success' }));
|
||||
await withPrelude(stubPrelude({ checkVerification }), async () => {
|
||||
const res = makeRes();
|
||||
await controller.handleConfirmPhone(
|
||||
makeReq({ code: '123456' }, { actor }),
|
||||
res,
|
||||
);
|
||||
expect(res.body).toMatchObject({ phone_verified: true });
|
||||
});
|
||||
expect(checkVerification).toHaveBeenCalledWith(
|
||||
'+14155550123',
|
||||
'123456',
|
||||
);
|
||||
const after = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.phone).toBe('+14155550123');
|
||||
});
|
||||
|
||||
it('throws 400 when the gate is set but no phone is on file', async () => {
|
||||
const { actor } = await makeUserAndActor({
|
||||
requires_phone_verification: 1,
|
||||
@@ -5006,6 +5070,32 @@ describe('AuthController password recovery', () => {
|
||||
expect(after!.pass_recovery_token).toBeTruthy();
|
||||
});
|
||||
|
||||
it('send-pass-recovery-email: refuses a team seat, and writes no token', async () => {
|
||||
// The seat's address is admin-supplied and unverified; recovery there
|
||||
// would be a takeover channel. Its recovery is the admin's reset.
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
const res = makeRes();
|
||||
await controller.handleSendPassRecoveryEmail(
|
||||
makeReq({ username: seat.username }),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { message: string }).message).toMatch(
|
||||
/If that account exists/i,
|
||||
);
|
||||
const after = await server.stores.user.getById(seat.id, { force: true });
|
||||
expect(after!.pass_recovery_token).toBeFalsy();
|
||||
});
|
||||
|
||||
it('verify-pass-recovery-token: 400 on missing token', async () => {
|
||||
await expect(
|
||||
controller.handleVerifyPassRecoveryToken(makeReq({}), makeRes()),
|
||||
@@ -5253,6 +5343,52 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('change-email: 403 for an account its team provisioned', async () => {
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat, actor } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.handleChangeEmail(
|
||||
makeReq({ new_email: `moved_${uniq()}@example.com` }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('change-username: 403 for an account its team provisioned', async () => {
|
||||
// The console lists members by username and the audit log records them
|
||||
// by it; a self-service rename would desync both.
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat, actor } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
// addMember refuses to adopt an account that has one.
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.handleChangeUsername(
|
||||
makeReq({ new_username: `r_${uniq()}` }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
const after = await server.stores.user.getById(seat.id, { force: true });
|
||||
expect(after!.username).toBe(seat.username);
|
||||
});
|
||||
|
||||
it('change-username: persists the rename and emits user.username-changed', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const newUsername = `r_${uniq()}`;
|
||||
@@ -5686,10 +5822,10 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
|
||||
const granted = `user:${user.uuid}:email:read`;
|
||||
const ungranted = `apps-of-user:${user.uuid}:read`;
|
||||
|
||||
const appActor = {
|
||||
const appActor = makeActor({
|
||||
user: actor.user,
|
||||
app: { id: app.id, uid: app.uid },
|
||||
} as unknown as Actor;
|
||||
});
|
||||
const before = makeRes();
|
||||
await inCtx(appActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
@@ -6487,6 +6623,28 @@ describe('AuthController.handleDeleteOwnUser', () => {
|
||||
expect(after).toBeFalsy();
|
||||
});
|
||||
|
||||
it('refuses, and keeps the row, for an account its team provisioned', async () => {
|
||||
// The team is billed for the seat and closing it is theirs to do, from
|
||||
// the console that keeps the audit trail.
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat, actor } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
const after = await server.stores.user.getById(seat.id, { force: true });
|
||||
expect(after).toBeTruthy();
|
||||
});
|
||||
|
||||
it('emits user.delete with the uuid + stripe customer id for downstream teardown', async () => {
|
||||
// `stripe_customer_id` ships in the MySQL/Postgres migrations but not
|
||||
// the sqlite ones the test harness runs — add it so the delete path
|
||||
|
||||
@@ -28,6 +28,10 @@ import type { HttpErrorOptions } from '../../core/http/HttpError.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { antiCsrf } from '../../core/http/middleware/antiCsrf.js';
|
||||
import { generateCaptcha } from '../../core/http/middleware/captcha.js';
|
||||
import {
|
||||
hasVerifiedCard,
|
||||
hasVerifiedPhone,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import {
|
||||
@@ -61,6 +65,7 @@ import {
|
||||
SEND_PHONE_RATE_LIMIT,
|
||||
SEND_PHONE_RATE_WINDOW_MS,
|
||||
cardFallbackAfterAttempts,
|
||||
cardFallbackDepsFrom,
|
||||
cardFallbackFlagKey,
|
||||
isCardFallbackEligible,
|
||||
isCardFallbackEnabled,
|
||||
@@ -196,20 +201,6 @@ const SESSION_LIMIT = {
|
||||
// enough to cover the typical support round-trip.
|
||||
const SMS_SEND_ERROR_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Whether this account has already been through the card flow. Both halves
|
||||
* matter: the pending flag being clear only says nobody asked, while the
|
||||
* fingerprint is the artifact a completed check leaves behind. A route that
|
||||
* requires a verified card (`requireCardVerified`) sends users here with the
|
||||
* flag clear and no card on file, and answering "already verified" on the flag
|
||||
* alone would bounce them between a dialog that reports success and a route
|
||||
* that keeps refusing.
|
||||
*/
|
||||
const cardAlreadyVerified = (user: {
|
||||
card_fingerprint?: string | null;
|
||||
requires_card_verification?: boolean;
|
||||
}): boolean =>
|
||||
Boolean(user.card_fingerprint) && !user.requires_card_verification;
|
||||
export const RESERVED_USERNAMES = new Set([
|
||||
'admin',
|
||||
'administrator',
|
||||
@@ -1621,18 +1612,7 @@ export class AuthController extends PuterController {
|
||||
* is listening on a stock build, which reads as "no card gate".
|
||||
*/
|
||||
private cardFallbackDeps(): CardFallbackDeps {
|
||||
return {
|
||||
smsConfigured: () => Boolean(this.clients.prelude?.isConfigured()),
|
||||
probeCardVerification: async () => {
|
||||
const statusEvent = { enabled: null as boolean | null };
|
||||
await this.clients.event?.emitAndWait(
|
||||
'puter.card-verification.status',
|
||||
statusEvent,
|
||||
{},
|
||||
);
|
||||
return statusEvent.enabled;
|
||||
},
|
||||
};
|
||||
return cardFallbackDepsFrom(this.clients);
|
||||
}
|
||||
|
||||
@Post('/send-confirm-phone', {
|
||||
@@ -1898,7 +1878,12 @@ export class AuthController extends PuterController {
|
||||
throw new HttpError(404, 'User not found.', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
if (!user.requires_phone_verification) {
|
||||
// Both halves, as for the card: the flag being clear only says nobody
|
||||
// asked. A route requiring a verified phone sends never-flagged users
|
||||
// here with no number on file, and answering "already verified" on
|
||||
// the flag alone would bounce them between a dialog that reports
|
||||
// success and a route that keeps refusing.
|
||||
if (hasVerifiedPhone(user)) {
|
||||
res.json({ phone_verified: true, original_client_socket_id });
|
||||
return;
|
||||
}
|
||||
@@ -2024,7 +2009,7 @@ export class AuthController extends PuterController {
|
||||
// Phone normally comes first, but the fallback lets a phone-gated user
|
||||
// in once they've exhausted SMS attempts.
|
||||
const fallbackEligible = await this.isCardFallbackEligible(user);
|
||||
if (cardAlreadyVerified(user) && !fallbackEligible) {
|
||||
if (hasVerifiedCard(user) && !fallbackEligible) {
|
||||
res.json({ card_verified: true });
|
||||
return;
|
||||
}
|
||||
@@ -2039,6 +2024,7 @@ export class AuthController extends PuterController {
|
||||
// extension always sets it (true/false) before doing any work.
|
||||
const setupEvent = {
|
||||
user_id: user.id,
|
||||
email: user.email!,
|
||||
user_uid: user.uuid,
|
||||
ip: (req.ip || req.socket?.remoteAddress || null) as string | null,
|
||||
device_fingerprint: req.deviceFingerprint ?? null,
|
||||
@@ -2144,7 +2130,7 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
// Same fallback exception as setup: card may come before phone.
|
||||
const fallbackEligible = await this.isCardFallbackEligible(user);
|
||||
if (cardAlreadyVerified(user) && !fallbackEligible) {
|
||||
if (hasVerifiedCard(user) && !fallbackEligible) {
|
||||
res.json({ card_verified: true });
|
||||
return;
|
||||
}
|
||||
@@ -2158,6 +2144,7 @@ export class AuthController extends PuterController {
|
||||
const confirmEvent = {
|
||||
user_id: user.id,
|
||||
user_uid: user.uuid,
|
||||
email: user.email!,
|
||||
setup_intent_id,
|
||||
enabled: null as boolean | null,
|
||||
verified: false,
|
||||
@@ -2293,6 +2280,14 @@ export class AuthController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
// A seat's address is admin-supplied and never verified, so whoever
|
||||
// holds that inbox could take the seat over. Its recovery channel is
|
||||
// the team admin's password reset, not this one.
|
||||
if (await this.stores.team.getOrgSeat(user.id)) {
|
||||
res.json({ message: genericMessage });
|
||||
return;
|
||||
}
|
||||
|
||||
const pass_recovery_token = uuidv4();
|
||||
await this.stores.user.update(user.id, { pass_recovery_token });
|
||||
|
||||
@@ -2551,6 +2546,17 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
async handleChangeUsername(req: Request, res: Response): Promise<void> {
|
||||
// A provisioned account's name belongs to the team that made it: the
|
||||
// console lists its members by username and the audit log records them
|
||||
// by username, so a self-service rename would desync both.
|
||||
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team set this username. Ask a team admin to change it.',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
|
||||
const { new_username } = req.body ?? {};
|
||||
if (!new_username || typeof new_username !== 'string') {
|
||||
throw new HttpError(400, '`new_username` is required', {
|
||||
@@ -2617,6 +2623,16 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
async handleChangeEmail(req: Request, res: Response): Promise<void> {
|
||||
// The address is where admin-issued credentials and team notices go;
|
||||
// same reasoning as the username and deletion guards above.
|
||||
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team set this address. Ask a team admin to change it.',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
|
||||
const { new_email } = req.body ?? {};
|
||||
if (!new_email || typeof new_email !== 'string') {
|
||||
throw new HttpError(400, '`new_email` is required', {
|
||||
@@ -3251,8 +3267,8 @@ export class AuthController extends PuterController {
|
||||
/**
|
||||
* Whether a granted `fs:` path permission needs its target created before
|
||||
* the grant can resolve, and if so where and what kind. Null when there is
|
||||
* nothing to create: `create` was not requested, the entry does not name an
|
||||
* `fs:` path, it is a `manage:` grant, or the path already exists.
|
||||
* nothing to create: `create` is `false`, the entry does not name an `fs:`
|
||||
* path, it is a `manage:` grant, or the path already exists.
|
||||
*
|
||||
* Side-effect-free, like `#prepareAppDataGrant` above — a pre-grant hook
|
||||
* run in the same validation loop, so a bad entry elsewhere in the list
|
||||
@@ -3308,11 +3324,11 @@ export class AuthController extends PuterController {
|
||||
// They are deliberately not rolled back if a later phase fails, so a
|
||||
// failed grant can leave empty directories inside the user's home.
|
||||
return plan.kind === 'dir'
|
||||
? await this.services.fs.mkdir(userId, {
|
||||
? await this.services.fs.mkdir(userId!, {
|
||||
path: plan.path,
|
||||
createMissingParents: true,
|
||||
})
|
||||
: await this.services.fs.touch(userId, {
|
||||
: await this.services.fs.touch(userId!, {
|
||||
path: plan.path,
|
||||
createMissingParents: true,
|
||||
});
|
||||
@@ -3331,7 +3347,7 @@ export class AuthController extends PuterController {
|
||||
const userId = actor.user!.id;
|
||||
for (const entry of [...created].reverse()) {
|
||||
try {
|
||||
await this.services.fs.remove(userId, {
|
||||
await this.services.fs.remove(userId!, {
|
||||
entry,
|
||||
recursive: false,
|
||||
});
|
||||
@@ -3366,7 +3382,7 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
// 400 on a bad value, before anything else runs.
|
||||
// 400 on a bad value, before anything else runs. Absent means create.
|
||||
const create = parseCreateFlag(req.body?.create);
|
||||
|
||||
// Validate every entry before writing or creating anything, so a bad
|
||||
@@ -4374,6 +4390,15 @@ export class AuthController extends PuterController {
|
||||
|
||||
async handleDeleteOwnUser(req: Request, res: Response): Promise<void> {
|
||||
const userId = req.actor!.user.id!;
|
||||
// The team owns the account and is billed for it; only they may close
|
||||
// it, through the console that keeps the audit trail.
|
||||
if (await this.stores.team.getOrgSeat(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team owns this account. Ask a team admin to remove it.',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
res.clearCookie(this.config.cookie_name ?? 'puter_token');
|
||||
res.clearCookie('puter_token_v2');
|
||||
res.clearCookie('puter_revalidation');
|
||||
@@ -4704,8 +4729,8 @@ export class AuthController extends PuterController {
|
||||
};
|
||||
try {
|
||||
await this.clients.event?.emitAndWait(
|
||||
'email.validate' as never,
|
||||
validateEvent as never,
|
||||
'email.validate',
|
||||
validateEvent,
|
||||
{},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -4816,6 +4841,7 @@ export class AuthController extends PuterController {
|
||||
phone?: string | null;
|
||||
requires_phone_verification?: number | boolean;
|
||||
requires_card_verification?: number | boolean;
|
||||
requires_password_change?: number | boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const meta = {
|
||||
@@ -4854,6 +4880,20 @@ export class AuthController extends PuterController {
|
||||
console.warn('[auth] taskbar_items resolution failed:', e);
|
||||
}
|
||||
|
||||
// Same shape as whoami: no-reload logins store this payload as
|
||||
// window.user verbatim, and every seat restriction keys on `team`.
|
||||
let team: { uid: string; name: string | null } | undefined;
|
||||
if (this.config.teams_enabled === true) {
|
||||
try {
|
||||
const seat = await this.stores.team.getOrgSeat(user.id);
|
||||
if (seat) {
|
||||
team = { uid: seat.team_uid, name: seat.team_name ?? null };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[auth] team lookup failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Response body gets the GUI token (client never sees session token)
|
||||
res.json({
|
||||
proceed: true,
|
||||
@@ -4868,7 +4908,9 @@ export class AuthController extends PuterController {
|
||||
phone: user.phone,
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
requires_password_change: user.requires_password_change,
|
||||
is_temp: user.password === null && user.email === null,
|
||||
...(team ? { team } : {}),
|
||||
taskbar_items,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,8 +47,7 @@ export class BroadcastController extends PuterController {
|
||||
@Post('/webhook', { subdomain: '*' })
|
||||
async webhook(req: Request, res: Response): Promise<void> {
|
||||
const broadcast = this.services.broadcast as unknown as
|
||||
| BroadcastService
|
||||
| undefined;
|
||||
BroadcastService | undefined;
|
||||
if (!broadcast) {
|
||||
res.status(503).json({
|
||||
error: { message: 'Broadcast service not registered' },
|
||||
@@ -80,8 +79,7 @@ export class BroadcastController extends PuterController {
|
||||
@Post('/events', { subdomain: '*' })
|
||||
async events(req: Request, res: Response): Promise<void> {
|
||||
const broadcast = this.services.broadcast as unknown as
|
||||
| BroadcastService
|
||||
| undefined;
|
||||
BroadcastService | undefined;
|
||||
if (!broadcast) {
|
||||
res.status(503).json({
|
||||
error: { message: 'Broadcast service not registered' },
|
||||
@@ -106,6 +104,7 @@ export class BroadcastController extends PuterController {
|
||||
|
||||
const reply = await this.services.eventForward.receive(
|
||||
(req.body ?? {}) as ForwardBatch,
|
||||
verified.peerId ?? '',
|
||||
);
|
||||
res.status(200).json({ ok: true, ...reply });
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
ORG_SEAT_FREE_SUBSCRIPTION,
|
||||
} from '../../services/metering/consts.js';
|
||||
import {
|
||||
EVENTS_BROADCAST_DELIVERY_LIMIT,
|
||||
@@ -58,6 +59,23 @@ describe('the tiered subscription quotas', () => {
|
||||
expect(tier.bySubscription[DEFAULT_TEMP_SUBSCRIPTION]).toBe(0);
|
||||
});
|
||||
|
||||
it('holds a free plan nobody enumerated to the free cap, not the paid one', () => {
|
||||
// A team seat resolves to `org_seat_free`, which no tier names. Falling
|
||||
// through to `limit` would give it more than an ordinary free account.
|
||||
for (const tier of tiers.map(([, t]) => t)) {
|
||||
expect(limitFor(tier, ORG_SEAT_FREE_SUBSCRIPTION)).toBe(
|
||||
tier.bySubscription[DEFAULT_FREE_SUBSCRIPTION],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('still holds an unresolved or paid plan to the base', () => {
|
||||
for (const tier of tiers.map(([, t]) => t)) {
|
||||
expect(limitFor(tier, null)).toBe(tier.limit);
|
||||
expect(limitFor(tier, 'some-paid-tier')).toBe(tier.limit);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps what one app may take below what the account may hold', () => {
|
||||
for (const plan of [
|
||||
null,
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { RouteRateLimit } from '../../core/http/types';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
FREE_SUBSCRIPTION_IDS,
|
||||
} from '../../services/metering/consts.js';
|
||||
|
||||
// -- Shared event limits ---------------------------------------------
|
||||
@@ -43,10 +44,9 @@ const userWindow = (
|
||||
): RouteRateLimit => ({ scope, limit, window, key: 'user' });
|
||||
|
||||
/**
|
||||
* A cap that varies by plan, in the shape route gates already declare theirs
|
||||
* in: the base is what a subscribed account sees, and `bySubscription` carves
|
||||
* the free tiers out beneath it. A plan nobody enumerated falls through to the
|
||||
* base, so a new one is generous rather than accidentally throttled.
|
||||
* A plan-varying cap in route-gate shape: `limit` is what a subscribed account
|
||||
* gets, `bySubscription` carves out the free tiers, unlisted plans get the
|
||||
* base.
|
||||
*/
|
||||
export interface TieredLimit {
|
||||
limit: number;
|
||||
@@ -61,14 +61,18 @@ const tiered = (paid: number, free: number, temp: number): TieredLimit => ({
|
||||
},
|
||||
});
|
||||
|
||||
/** The cap one plan sees. An unresolved plan is held to the base. */
|
||||
/** The cap one plan sees; an unlisted free plan takes the free one, not `limit`. */
|
||||
export const limitFor = (
|
||||
tier: TieredLimit,
|
||||
subscriptionId: string | null,
|
||||
): number =>
|
||||
(subscriptionId === null
|
||||
? undefined
|
||||
: tier.bySubscription[subscriptionId]) ?? tier.limit;
|
||||
): number => {
|
||||
if (subscriptionId === null) return tier.limit;
|
||||
const own = tier.bySubscription[subscriptionId];
|
||||
if (typeof own === 'number') return own;
|
||||
return FREE_SUBSCRIPTION_IDS.has(subscriptionId)
|
||||
? (tier.bySubscription[DEFAULT_FREE_SUBSCRIPTION] ?? tier.limit)
|
||||
: tier.limit;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a plan-tiered quota resolves to: what an account may hold, and what one
|
||||
@@ -258,6 +262,14 @@ export const EVENTS_WORKER_LIST_LIMIT = userWindow('events:workers:list', 120);
|
||||
*/
|
||||
export const EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT = 50;
|
||||
|
||||
/**
|
||||
* Largest key-value value a delivery inlines, in serialized bytes. A value over
|
||||
* this is left out and the subscriber re-reads the key: a delivery fans out to
|
||||
* many rows, may cross regions and may sit in a backlog, none of which is sized
|
||||
* for the store's own ceiling.
|
||||
*/
|
||||
export const EVENTS_KV_VALUE_MAX_BYTES = 16 * 1024;
|
||||
|
||||
/**
|
||||
* Broadcast deliveries per minute, per subscription.
|
||||
*
|
||||
|
||||
@@ -238,7 +238,7 @@ export class EventsWorkerDeployer {
|
||||
const user = await stores.user.getById(ownerUserId);
|
||||
if (!user?.id || !user.uuid || !user.username || user.suspended)
|
||||
return null;
|
||||
return { user } as Actor & {
|
||||
return { user, effectiveApp: null } as Actor & {
|
||||
user: { id: number; uuid: string; username: string };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,9 +23,11 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { SHARE_LIST_LIMIT } from '../share/limits.js';
|
||||
import type { FSController } from './FSController.js';
|
||||
import type {
|
||||
ClientSignedWriteResponse,
|
||||
@@ -798,6 +800,40 @@ describe('FSController.statEntry', () => {
|
||||
statusCode: 401,
|
||||
});
|
||||
});
|
||||
|
||||
it('draws return_shares from the share-listing budget', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const path = `/${username}/Documents/share-budget`;
|
||||
await withActor(actor, () =>
|
||||
controller.mkdirEntry(makeReq({ body: { path }, actor }), makeRes().res),
|
||||
);
|
||||
|
||||
// Spend the whole share:list bucket, as /share/shares' gate would.
|
||||
const chargeReq = makeReq({ body: {}, actor });
|
||||
for (let i = 0; i < SHARE_LIST_LIMIT.limit; i++) {
|
||||
await consumeRouteRateLimit(chargeReq, SHARE_LIST_LIMIT);
|
||||
}
|
||||
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.statEntry(
|
||||
makeReq({ body: { path, return_shares: true }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
|
||||
// A plain stat spends only its own budget, so it still admits.
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.statEntry(makeReq({ body: { path }, actor }), res),
|
||||
);
|
||||
expect((captured.body as { name: string }).name).toBe('share-budget');
|
||||
});
|
||||
});
|
||||
|
||||
// ── /readdir (readdirEntries) ───────────────────────────────────────
|
||||
@@ -1822,6 +1858,44 @@ describe('FSController.readdirEntries recursive', () => {
|
||||
).toEqual(['l1a', 'l1a/l2a', 'l1a/l2a/l3a', 'l1a/l2a/l3a/l4a', 'l1b']);
|
||||
});
|
||||
|
||||
it('sorts a recursive listing, paging in the same order', async () => {
|
||||
const { actor, base } = await makeTree();
|
||||
// A recursive `name` sort is path order, so descending walks the
|
||||
// subtree backwards.
|
||||
const descending = (await readdir(actor, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
depth: 10,
|
||||
sortBy: 'name',
|
||||
sortOrder: 'desc',
|
||||
})) as { items: Array<{ path: string }> };
|
||||
expect(
|
||||
descending.items.map((e) => e.path.slice(base.length + 1)),
|
||||
).toEqual(['l1b', 'l1a/l2a/l3a/l4a', 'l1a/l2a/l3a', 'l1a/l2a', 'l1a']);
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
do {
|
||||
const page = (await readdir(actor, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
depth: 10,
|
||||
sortOrder: 'desc',
|
||||
limit: 2,
|
||||
cursor,
|
||||
})) as { items: Array<{ path: string }>; cursor?: string };
|
||||
seen.push(...page.items.map((e) => e.path.slice(base.length + 1)));
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
expect(seen).toEqual([
|
||||
'l1b',
|
||||
'l1a/l2a/l3a/l4a',
|
||||
'l1a/l2a/l3a',
|
||||
'l1a/l2a',
|
||||
'l1a',
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts the subtree with includeTotal', async () => {
|
||||
const { actor, base } = await makeTree();
|
||||
const page = (await readdir(actor, {
|
||||
|
||||
@@ -47,6 +47,8 @@ import {
|
||||
} from '../../util/concurrency.js';
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { listClientShares } from '../share/clientShare.js';
|
||||
import { SHARE_LIST_LIMIT } from '../share/limits.js';
|
||||
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { STORAGE_OP_COSTS } from '../../services/metering/costs.js';
|
||||
import {
|
||||
@@ -328,6 +330,9 @@ export class FSController extends PuterController {
|
||||
req.body,
|
||||
);
|
||||
this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData);
|
||||
await this.#assertUploadSessionWriteAccess(req, userId, [
|
||||
requestBody.uploadId,
|
||||
]);
|
||||
|
||||
const response = await this.services.fs.completeUrlWrite(
|
||||
userId,
|
||||
@@ -373,6 +378,11 @@ export class FSController extends PuterController {
|
||||
for (const requestBody of requests) {
|
||||
this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData);
|
||||
}
|
||||
await this.#assertUploadSessionWriteAccess(
|
||||
req,
|
||||
userId,
|
||||
requests.map((requestBody) => requestBody.uploadId),
|
||||
);
|
||||
const response = await this.services.fs.batchCompleteUrlWrite(
|
||||
userId,
|
||||
requests,
|
||||
@@ -433,6 +443,9 @@ export class FSController extends PuterController {
|
||||
res: Response<ClientSignMultipartPartsResponse>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
await this.#assertUploadSessionWriteAccess(req, userId, [
|
||||
req.body?.uploadId,
|
||||
]);
|
||||
const response = await this.services.fs.signMultipartParts(
|
||||
userId,
|
||||
req.body,
|
||||
@@ -991,6 +1004,17 @@ export class FSController extends PuterController {
|
||||
await this.#assertAccess(actor, entry.path, 'see');
|
||||
|
||||
const wantsSize = this.#toBoolean(body.return_size);
|
||||
const wantsShares = this.#toBoolean(body.return_shares);
|
||||
// `return_shares` is the share-listing route's work, so it spends from
|
||||
// the share-listing budget too, not just `fs:stat`'s.
|
||||
if (
|
||||
wantsShares &&
|
||||
!(await consumeRouteRateLimit(req, SHARE_LIST_LIMIT))
|
||||
) {
|
||||
throw new HttpError(429, 'Too many requests.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
}
|
||||
const [subtreeSize, suggestedApps, shareFlags, shares] =
|
||||
await Promise.all([
|
||||
entry.isDir && wantsSize
|
||||
@@ -998,7 +1022,7 @@ export class FSController extends PuterController {
|
||||
: undefined,
|
||||
this.services.suggestedApps.getSuggestedApps(entry),
|
||||
this.services.share.shareFlags(actor, [entry]),
|
||||
this.#toBoolean(body.return_shares)
|
||||
wantsShares
|
||||
? listClientShares(
|
||||
this.services.share,
|
||||
this.clients.event,
|
||||
@@ -1140,8 +1164,8 @@ export class FSController extends PuterController {
|
||||
Object.prototype.hasOwnProperty.call(body, 'cursor') ||
|
||||
includeTotal;
|
||||
|
||||
// Undocumented: `recursive` lists descendants (prefix scan) up to
|
||||
// `depth` levels below the target. Always paginated; sorts by path.
|
||||
// `recursive` lists descendants (prefix scan) up to `depth` levels
|
||||
// below the target. Always paginated.
|
||||
const recursive = this.#toBoolean(body.recursive) === true;
|
||||
|
||||
if (this.#isRootPathRef(body)) {
|
||||
@@ -1230,6 +1254,8 @@ export class FSController extends PuterController {
|
||||
? body.cursor
|
||||
: undefined,
|
||||
maxDepth,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
},
|
||||
);
|
||||
await this.#attachSuggestedApps(page.entries);
|
||||
@@ -2234,8 +2260,7 @@ export class FSController extends PuterController {
|
||||
// the ActorUser type. Access via the escape hatch until a proper
|
||||
// storage-quota mechanism is in place.
|
||||
const actorUser = req.actor?.user as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
|
||||
const candidates = [
|
||||
this.#toStorageCapacityCandidate(actorUser?.free_storage),
|
||||
@@ -2509,6 +2534,40 @@ export class FSController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An upload session stays usable for as long as it lives, so the access
|
||||
* `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.
|
||||
*/
|
||||
async #assertUploadSessionWriteAccess(
|
||||
req: Request,
|
||||
userId: number,
|
||||
uploadIds: Array<string | undefined>,
|
||||
): Promise<void> {
|
||||
// A malformed id is left to the service, which owns that error shape.
|
||||
const knownUploadIds = uploadIds.filter(
|
||||
(uploadId): uploadId is string =>
|
||||
typeof uploadId === 'string' && uploadId.length > 0,
|
||||
);
|
||||
if (knownUploadIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const sessions = await this.services.fs.getUploadSessions(
|
||||
userId,
|
||||
knownUploadIds,
|
||||
);
|
||||
await this.#assertBatchWriteAccess(
|
||||
req,
|
||||
sessions.map((session) => ({
|
||||
path: session.targetPath,
|
||||
size: session.size,
|
||||
overwrite: Boolean(session.overwriteTargetUid),
|
||||
})),
|
||||
{ pathAlreadyNormalized: true },
|
||||
);
|
||||
}
|
||||
|
||||
#toEventGuiMetadata(
|
||||
guiMetadata: WriteGuiMetadata | undefined,
|
||||
includeOriginalClientSocketId = true,
|
||||
|
||||
@@ -1921,3 +1921,211 @@ describe('FSController.batchWrites (multipart)', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -- upload sessions outliving their share ----------------------------
|
||||
//
|
||||
// An upload session is authorized once, at `/fs/startWrite`. Resuming or
|
||||
// completing one has to re-check that the caller still has write access to the
|
||||
// session's target, or a revoked sharee lands bytes in the owner's tree.
|
||||
|
||||
describe('FSController upload session access re-checks', () => {
|
||||
const shareWritableFolder = async (folderName: string) => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const folder = `/${owner.username}/Documents/${folderName}`;
|
||||
await withActor(owner.actor, () =>
|
||||
controller.startWrite(
|
||||
makeReq<SignedWriteRequest>({
|
||||
body: {
|
||||
fileMetadata: {
|
||||
path: folder,
|
||||
size: 0,
|
||||
createMissingParents: true,
|
||||
},
|
||||
directory: true,
|
||||
},
|
||||
actor: owner.actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(folder);
|
||||
// Read stays granted so the denial is a plain 403 rather than the
|
||||
// existence-masking 404 an invisible path gets.
|
||||
for (const mode of ['read', 'write']) {
|
||||
await server.services.permission.grantUserUserPermission(
|
||||
owner.actor,
|
||||
recipient.username,
|
||||
`fs:${entry!.uuid}:${mode}`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
const revokeWrite = () =>
|
||||
server.services.permission.revokeUserUserPermission(
|
||||
owner.actor,
|
||||
recipient.username,
|
||||
`fs:${entry!.uuid}:write`,
|
||||
);
|
||||
return { owner, recipient, folder, revokeWrite };
|
||||
};
|
||||
|
||||
const startUpload = async (
|
||||
actor: Actor,
|
||||
path: string,
|
||||
extra: Partial<SignedWriteRequest> = {},
|
||||
) => {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.startWrite(
|
||||
makeReq<SignedWriteRequest>({
|
||||
body: { fileMetadata: { path, size: 8 }, ...extra },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
return captured.body as ClientSignedWriteResponse;
|
||||
};
|
||||
|
||||
it('refuses to sign more parts once the share is revoked', async () => {
|
||||
const { recipient, folder, revokeWrite } =
|
||||
await shareWritableFolder('revoked-parts');
|
||||
const started = await startUpload(
|
||||
recipient.actor,
|
||||
`${folder}/upload.bin`,
|
||||
{ uploadMode: 'multipart' },
|
||||
);
|
||||
await revokeWrite();
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(recipient.actor, () =>
|
||||
controller.signMultipartParts(
|
||||
makeReq<SignMultipartPartsRequest>({
|
||||
body: {
|
||||
uploadId: started.sessionId,
|
||||
partNumbers: [1],
|
||||
},
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('refuses to complete a session once the share is revoked', async () => {
|
||||
const { recipient, folder, revokeWrite } =
|
||||
await shareWritableFolder('revoked-complete');
|
||||
const target = `${folder}/upload.bin`;
|
||||
const started = await startUpload(recipient.actor, target);
|
||||
await revokeWrite();
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(recipient.actor, () =>
|
||||
controller.completeWrite(
|
||||
makeReq<CompleteWriteRequest>({
|
||||
body: { uploadId: started.sessionId },
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
expect(await server.stores.fsEntry.getEntryByPath(target)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses the batch completion once the share is revoked', async () => {
|
||||
const { recipient, folder, revokeWrite } =
|
||||
await shareWritableFolder('revoked-batch');
|
||||
const targets = [`${folder}/batch-a.bin`, `${folder}/batch-b.bin`];
|
||||
const started = [
|
||||
await startUpload(recipient.actor, targets[0]!),
|
||||
await startUpload(recipient.actor, targets[1]!),
|
||||
];
|
||||
await revokeWrite();
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(recipient.actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: started.map((s) => ({ uploadId: s.sessionId })),
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
for (const target of targets) {
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(target),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('still signs parts and completes while the share stands', async () => {
|
||||
const { recipient, folder } = await shareWritableFolder('kept-share');
|
||||
const multipart = await startUpload(
|
||||
recipient.actor,
|
||||
`${folder}/kept-parts.bin`,
|
||||
{ uploadMode: 'multipart' },
|
||||
);
|
||||
const signed = makeRes();
|
||||
await withActor(recipient.actor, () =>
|
||||
controller.signMultipartParts(
|
||||
makeReq<SignMultipartPartsRequest>({
|
||||
body: { uploadId: multipart.sessionId, partNumbers: [1] },
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
signed.res,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(signed.captured.body as { multipartPartUrls: unknown[] })
|
||||
.multipartPartUrls,
|
||||
).toHaveLength(1);
|
||||
|
||||
const target = `${folder}/kept-complete.bin`;
|
||||
const single = await startUpload(recipient.actor, target);
|
||||
await withActor(recipient.actor, () =>
|
||||
controller.completeWrite(
|
||||
makeReq<CompleteWriteRequest>({
|
||||
body: { uploadId: single.sessionId },
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(target),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('completes a batch while the share stands', async () => {
|
||||
const { recipient, folder } = await shareWritableFolder('kept-batch');
|
||||
const targets = [
|
||||
`${folder}/kept-batch-a.bin`,
|
||||
`${folder}/kept-batch-b.bin`,
|
||||
];
|
||||
const started = [
|
||||
await startUpload(recipient.actor, targets[0]!),
|
||||
await startUpload(recipient.actor, targets[1]!),
|
||||
];
|
||||
await withActor(recipient.actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: started.map((s) => ({ uploadId: s.sessionId })),
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
for (const target of targets) {
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(target),
|
||||
).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,13 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { signFile } from '../../util/fileSigning.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { SHARE_LIST_LIMIT } from '../share/limits.js';
|
||||
import type { LegacyFSController } from './LegacyFSController.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
@@ -426,6 +428,40 @@ describe('LegacyFSController.stat', () => {
|
||||
statusCode: 401,
|
||||
});
|
||||
});
|
||||
|
||||
it('draws return_shares from the share-listing budget', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const path = `/${username}/Documents/share-budget`;
|
||||
await withActor(actor, () =>
|
||||
controller.mkdir(makeReq({ body: { path }, actor }), makeRes().res),
|
||||
);
|
||||
|
||||
// Spend the whole share:list bucket, as /share/shares' gate would.
|
||||
const chargeReq = makeReq({ body: {}, actor });
|
||||
for (let i = 0; i < SHARE_LIST_LIMIT.limit; i++) {
|
||||
await consumeRouteRateLimit(chargeReq, SHARE_LIST_LIMIT);
|
||||
}
|
||||
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.stat(
|
||||
makeReq({ body: { path, return_shares: true }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
|
||||
// A plain stat spends only its own budget, so it still admits.
|
||||
const plain = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.stat(makeReq({ body: { path }, actor }), plain.res),
|
||||
);
|
||||
expect(plain.captured.body).toMatchObject({ name: 'share-budget' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('LegacyFSController.delete', () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { posix as pathPosix } from 'node:path';
|
||||
import {
|
||||
assertResolvedActor,
|
||||
isAccessTokenActor,
|
||||
isAppActor,
|
||||
makeActor,
|
||||
} from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
@@ -50,6 +51,8 @@ import {
|
||||
} from '../../util/hostedAppBacking.js';
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { listClientShares } from '../share/clientShare.js';
|
||||
import { SHARE_LIST_LIMIT } from '../share/limits.js';
|
||||
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import {
|
||||
FS_BATCH_CONCURRENT,
|
||||
@@ -487,6 +490,13 @@ export class LegacyFSController extends PuterController {
|
||||
// callers working. `return_owner` is a no-op — `owner` is always set.
|
||||
if (getBoolean(body, 'return_versions')) shaped.versions = [];
|
||||
if (getBoolean(body, 'return_shares')) {
|
||||
// Share-listing work spends the share-listing budget, not just
|
||||
// `fs:stat`'s.
|
||||
if (!(await consumeRouteRateLimit(req, SHARE_LIST_LIMIT))) {
|
||||
throw new HttpError(429, 'Too many requests.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
}
|
||||
shaped.shares = await listClientShares(
|
||||
this.services.share,
|
||||
this.clients.event,
|
||||
@@ -859,9 +869,7 @@ export class LegacyFSController extends PuterController {
|
||||
// Trash, and `null`/`{}` when restoring. See
|
||||
// `src/gui/src/helpers.js` → `window.move_items`.
|
||||
newMetadata: (body.new_metadata ?? undefined) as
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined,
|
||||
Record<string, unknown> | null | undefined,
|
||||
});
|
||||
const oldPath = source.path;
|
||||
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
|
||||
@@ -1291,15 +1299,14 @@ export class LegacyFSController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const isApp = Boolean((actor as { app?: unknown }).app);
|
||||
const actingApp = actor.effectiveApp;
|
||||
const signingCfg = signingConfigFromAppConfig(this.config);
|
||||
|
||||
// Apps can only sign inside their AppData root.
|
||||
let appDataRoot: string | null = null;
|
||||
if (isApp) {
|
||||
const username = (actor as { user?: { username?: string } }).user
|
||||
?.username;
|
||||
const appUid = (actor as { app?: { uid?: string } }).app?.uid;
|
||||
if (actingApp) {
|
||||
const username = actor.user?.username;
|
||||
const appUid = actingApp.uid;
|
||||
if (!username || !appUid)
|
||||
throw new HttpError(403, 'Forbidden', {
|
||||
legacyCode: 'forbidden',
|
||||
@@ -1308,8 +1315,7 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
type SignedOrEmpty =
|
||||
| (SignedFile & { path?: string })
|
||||
| Record<string, never>;
|
||||
(SignedFile & { path?: string }) | Record<string, never>;
|
||||
const result: { signatures: SignedOrEmpty[]; token?: string } = {
|
||||
signatures: [],
|
||||
};
|
||||
@@ -1795,7 +1801,7 @@ export class LegacyFSController extends PuterController {
|
||||
*/
|
||||
openItem = async (req: Request, res: Response): Promise<void> => {
|
||||
const actor = this.#requireActor(req);
|
||||
if ((actor as { app?: unknown }).app) {
|
||||
if (actor.effectiveApp) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This endpoint is only available to user sessions',
|
||||
@@ -1889,8 +1895,10 @@ export class LegacyFSController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const actorApp = (actor as { app?: { uid?: string } }).app;
|
||||
if (!actorApp?.uid || actorApp.uid !== appUid) {
|
||||
// The app acting directly, not one that merely issued the credential:
|
||||
// claiming a root dir is the app itself asking, and a token is not it.
|
||||
const callerApp = isAppActor(actor) ? actor.effectiveApp : null;
|
||||
if (callerApp?.uid !== appUid) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Only the app itself may request its root dir',
|
||||
@@ -1930,10 +1938,7 @@ export class LegacyFSController extends PuterController {
|
||||
const subjectRef = body.subject;
|
||||
const appRef = body.app;
|
||||
const mode = (getString(body, 'mode') ?? 'read') as
|
||||
| 'see'
|
||||
| 'list'
|
||||
| 'read'
|
||||
| 'write';
|
||||
'see' | 'list' | 'read' | 'write';
|
||||
if (!subjectRef || !appRef)
|
||||
throw new HttpError(400, '`subject` and `app` are required', {
|
||||
legacyCode: 'bad_request',
|
||||
|
||||
@@ -135,24 +135,11 @@ export interface WriteResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* An `FSEntry` as it is safe to hand to a client. Built by an allowlist, so the
|
||||
* `fsentries` primary key (`id`) and its `parentId`/`associatedAppId`
|
||||
* references, the storage columns (`bucket`, `bucketRegion`), the owning
|
||||
* `userId`, and the `publicToken`/`fileRequestToken` capability tokens never
|
||||
* reach the wire. Entries are addressed by `uuid`.
|
||||
*
|
||||
* `shortcutTo` is the one numeric row reference that stays: the v1 contract has
|
||||
* always exposed it as `shortcut_to` and the desktop resolves shortcuts through
|
||||
* it, so dropping it would break them.
|
||||
*
|
||||
* The `?: never` members below are guards, not fields: they make a raw
|
||||
* `FSEntry` fail to typecheck wherever a `ClientFSEntry` is expected. Without
|
||||
* them this type is just a structural subset of `FSEntry`, so an unsanitized
|
||||
* row would be silently assignable and the distinction would buy nothing.
|
||||
*
|
||||
* Checked by `tsc -p tsconfig.json` (the strict config). Note that
|
||||
* `tsconfig.build.json` sets `noCheck: true` — it only transpiles, so it will
|
||||
* not catch a violation here.
|
||||
* An `FSEntry` safe to send to a client. Built by allowlist so row ids, storage
|
||||
* columns and capability tokens never reach the wire; `shortcutTo` stays
|
||||
* because the v1 contract exposes it. The `?: never` members make a raw
|
||||
* `FSEntry` fail to typecheck where a `ClientFSEntry` is expected; only the
|
||||
* strict `tsconfig.json` catches that, since the build config sets `noCheck`.
|
||||
*/
|
||||
export interface ClientFSEntry {
|
||||
uuid: string;
|
||||
@@ -189,17 +176,12 @@ export interface ClientFSEntry {
|
||||
fileRequestToken?: never;
|
||||
}
|
||||
|
||||
// Wire counterparts of the write responses: what the controller sends after
|
||||
// sanitizing, kept distinct so the compiler can tell the two apart.
|
||||
|
||||
/**
|
||||
* Wire counterparts of the write responses. The bare `…Response` types describe
|
||||
* what the service produces internally (a real `FSEntry`); these describe what
|
||||
* the controller sends after sanitizing. Keeping them distinct is what makes
|
||||
* "did this response get sanitized?" a question the compiler answers.
|
||||
*/
|
||||
/**
|
||||
* The presigned-upload envelope minus the storage internals. A client uploads
|
||||
* to the presigned `url` / `multipartPartUrls`, which already carry everything
|
||||
* S3 needs, so `bucket`, `bucketRegion`, and `objectKey` are ours to keep —
|
||||
* they name where a user's bytes physically live.
|
||||
* The presigned-upload envelope minus storage internals; the presigned URLs
|
||||
* already carry what the store needs.
|
||||
*/
|
||||
export type ClientSignedWriteResponse = Omit<
|
||||
SignedWriteResponse,
|
||||
@@ -221,10 +203,8 @@ export type ClientWriteResponse = Omit<WriteResponse, 'fsEntry'> & {
|
||||
};
|
||||
|
||||
/**
|
||||
* A directory-listing entry: a sanitized entry plus the three fields a client
|
||||
* cannot derive on its own — the MIME `type`, a _signed_ `thumbnail` URL (the
|
||||
* stored value is an S3 key, which is useless to a client), and the resolved
|
||||
* `associatedApp`.
|
||||
* A sanitized entry plus what a client cannot derive: MIME `type`, a signed
|
||||
* `thumbnail` URL, the resolved `associatedApp`.
|
||||
*/
|
||||
export type ClientReaddirEntry = ClientFSEntry & {
|
||||
type: string | null;
|
||||
|
||||
@@ -955,6 +955,17 @@ describe('OIDCController login callback', () => {
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toContain('embedded_in_popup=true');
|
||||
expect(captured.redirectUrl).toContain('oidc_login=true');
|
||||
|
||||
// The proof must bind to the account that just completed OIDC so the
|
||||
// popup can refuse a proof replayed in another signed-in browser.
|
||||
const openerState = new URL(captured.redirectUrl!).searchParams.get(
|
||||
'opener_state',
|
||||
);
|
||||
const proof = oidc().verifyPopupReturn(openerState!);
|
||||
expect(proof?.oidc_login).toBe(true);
|
||||
const user = await server.stores.user.getByEmail(email);
|
||||
expect(user?.uuid).toBeTruthy();
|
||||
expect(proof?.user_uuid).toBe(user!.uuid);
|
||||
});
|
||||
|
||||
it('uses popup-style error URL (msg_id + opener_origin) when the popup-state user is suspended', async () => {
|
||||
@@ -1846,9 +1857,23 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
|
||||
opener_origin: 'https://opener.test',
|
||||
msg_id: '77',
|
||||
oidc_login: true,
|
||||
user_uuid: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('hands back the account a proof is bound to', async () => {
|
||||
// The popup compares this against its current user to reject a proof
|
||||
// replayed from another account's login.
|
||||
const proof = server.services.oidc.signPopupReturn({
|
||||
opener_origin: 'https://opener.test',
|
||||
msg_id: '77',
|
||||
oidc_login: true,
|
||||
user_uuid: 'user-A',
|
||||
});
|
||||
const captured = await redeem(proof);
|
||||
expect(captured.body).toMatchObject({ user_uuid: 'user-A' });
|
||||
});
|
||||
|
||||
it('rejects a proof signed with someone else’s key', async () => {
|
||||
// The whole point: only the server can mint one of these.
|
||||
const forged = jwt.sign(
|
||||
|
||||
@@ -302,6 +302,7 @@ export class OIDCController extends PuterController {
|
||||
opener_origin: decoded.opener_origin ?? null,
|
||||
msg_id: decoded.msg_id ?? null,
|
||||
oidc_login: decoded.oidc_login === true,
|
||||
user_uuid: decoded.user_uuid ?? null,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -924,6 +925,11 @@ if (window.opener) {
|
||||
// identity to mint a token for, so it needs the integrity this
|
||||
// state already carries — re-signed here, at the one point where
|
||||
// the round trip is known to have actually happened.
|
||||
//
|
||||
// `user_uuid` binds the proof to the account that just completed
|
||||
// OIDC. Without it a proof from one login could be replayed in
|
||||
// another signed-in browser to skip its account picker; the popup
|
||||
// only honors `oidc_login` when this matches its current user.
|
||||
target = appendQueryParam(
|
||||
target,
|
||||
'opener_state',
|
||||
@@ -931,6 +937,7 @@ if (window.opener) {
|
||||
opener_origin: stateDecoded.opener_origin ?? null,
|
||||
msg_id: stateDecoded.msg_id ?? null,
|
||||
oidc_login: true,
|
||||
user_uuid: user.uuid,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
@@ -382,10 +383,10 @@ describe('PeerController TURN', () => {
|
||||
json: async () => ({ iceServers: [] }),
|
||||
} as never);
|
||||
try {
|
||||
const appActor = {
|
||||
const appActor = makeActor({
|
||||
...userActor,
|
||||
app: { uid: 'app-66666666-7777-8888-9999-aaaaaaaaaaaa' },
|
||||
};
|
||||
});
|
||||
await generateTurn(makeReq({ actor: appActor }), makeRes().res);
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
const { customIdentifier } = JSON.parse(init.body as string) as {
|
||||
@@ -667,10 +668,12 @@ describe('PeerController guest TURN', () => {
|
||||
});
|
||||
|
||||
it('carries the app segment for an app-under-user host', () => {
|
||||
const grant = mintGrant({
|
||||
...hostActor,
|
||||
app: { uid: 'app-66666666-7777-8888-9999-aaaaaaaaaaaa' },
|
||||
});
|
||||
const grant = mintGrant(
|
||||
makeActor({
|
||||
...hostActor,
|
||||
app: { uid: 'app-66666666-7777-8888-9999-aaaaaaaaaaaa' },
|
||||
}),
|
||||
);
|
||||
|
||||
const verified = verifyGuestGrant({
|
||||
grant,
|
||||
|
||||
@@ -98,8 +98,9 @@ const base64urlToUuid = (encoded: string): string | null => {
|
||||
*/
|
||||
const actorToTurnIdentifier = (actor: Actor): string => {
|
||||
const userPart = uuidToBase64url(actor.user.uuid);
|
||||
if (!actor.app) return userPart;
|
||||
return `${userPart}:${uuidToBase64url(actor.app.uid)}`;
|
||||
const appUid = actor.effectiveApp?.uid;
|
||||
if (!appUid) return userPart;
|
||||
return `${userPart}:${uuidToBase64url(appUid)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { toMicroCents } from '../../services/metering/utils';
|
||||
|
||||
// Microcents per byte of TURN egress ($0.05/GB).
|
||||
export const PEER_COSTS = {
|
||||
'turn:egress-bytes': 0.005,
|
||||
'turn:egress-bytes': toMicroCents(0.1 / 1000 ** 3),
|
||||
} as const;
|
||||
@@ -31,28 +31,7 @@ import { toClientShare } from './clientShare.js';
|
||||
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
|
||||
import { normalizeLimit } from '../../util/pagination.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
/**
|
||||
* Two windows: a burst ceiling, and a daily one so a slow drip can't add up to
|
||||
* a mail-merge. Neither bounds _shares_ — one request carries many — which is
|
||||
* what `ShareService`'s per-day quota is for.
|
||||
*/
|
||||
const SHARE_LIMIT = [
|
||||
{ scope: 'share:mutate', limit: 60, window: 60_000, key: 'user' as const },
|
||||
{
|
||||
scope: 'share:mutate-daily',
|
||||
limit: 500,
|
||||
window: 24 * 60 * 60_000,
|
||||
key: 'user' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const SHARE_LIST_LIMIT = {
|
||||
scope: 'share:list',
|
||||
limit: 600,
|
||||
window: 60_000,
|
||||
key: 'user' as const,
|
||||
};
|
||||
import { SHARE_LIMIT, SHARE_LIST_LIMIT } from './limits.js';
|
||||
|
||||
/** Distinct (holder, item) pairs run together; see the note on grouping below. */
|
||||
const SHARE_CONCURRENCY = 8;
|
||||
@@ -104,10 +83,15 @@ export class ShareController extends PuterController {
|
||||
* POST /share — grant `mode` on one or more items to one or more
|
||||
* recipients. Partial success is the contract: each pair reports its own
|
||||
* outcome and the envelope summarizes.
|
||||
*
|
||||
* Handing out access is the one share surface that reaches other people, so
|
||||
* it asks for a verified phone or card first; withdrawing and listing never
|
||||
* do — a caller must always be able to see and undo what it shared.
|
||||
*/
|
||||
@Post('', {
|
||||
subdomain: 'api',
|
||||
requireVerified: true,
|
||||
requireAnyVerified: ['phone', 'card'],
|
||||
rateLimit: SHARE_LIMIT,
|
||||
})
|
||||
async createShares(req: Request, res: Response): Promise<void> {
|
||||
@@ -644,6 +628,7 @@ export class ShareController extends PuterController {
|
||||
|
||||
/** Echoes back the identifier the caller named, so results are matchable. */
|
||||
#recipientLabel(recipient: ShareRecipient): string {
|
||||
if (recipient.anyone) return 'anyone';
|
||||
return (
|
||||
recipient.email ??
|
||||
recipient.username ??
|
||||
@@ -670,6 +655,12 @@ export class ShareController extends PuterController {
|
||||
}
|
||||
if (entry && typeof entry === 'object') {
|
||||
const rec = entry as Record<string, unknown>;
|
||||
// Object form only, and the literal `true`: nothing typed into
|
||||
// a people field can become "everyone".
|
||||
if (rec.anyone === true) {
|
||||
out.push({ anyone: true });
|
||||
continue;
|
||||
}
|
||||
const email =
|
||||
typeof rec.email === 'string' ? rec.email.trim() : '';
|
||||
const username =
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* 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, vi } from 'vitest';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
type TestUserCredentials,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { resetCardVerificationStatusCache } from '../../util/cardFallback.js';
|
||||
|
||||
/**
|
||||
* Handing out a share asks for a verified phone or card. Its own env, with an
|
||||
* SMS provider configured: the sibling share tests run without one, where the
|
||||
* gate is inert by design.
|
||||
*/
|
||||
describe('share verification gate over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
prelude: { apiKey: 'test-key' },
|
||||
} as IConfig);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const post = (path: string, token: string, body: unknown) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const get = (path: string, token: string) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
/** A fresh account, optionally with a verification artifact on its row. */
|
||||
const makeUser = async (patch: Record<string, unknown> = {}) => {
|
||||
const username = `svg${Math.random().toString(36).slice(2, 9)}`;
|
||||
const creds = await createTestUser(env.server, {
|
||||
username,
|
||||
password: 'puter-test-user-password',
|
||||
});
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const row = await env.server.stores.user.getByUsername(username);
|
||||
await env.server.stores.user.update(row!.id, patch);
|
||||
}
|
||||
return creds;
|
||||
};
|
||||
|
||||
const makeFile = async (owner: { username: string }) => {
|
||||
const uid = crypto.randomUUID();
|
||||
const name = `share-gate-${uid.slice(0, 8)}.txt`;
|
||||
const path = `/${owner.username}/${name}`;
|
||||
const user = await env.server.stores.user.getByUsername(owner.username);
|
||||
await env.server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, 0, ?)',
|
||||
[uid, name, path, user!.id, Math.floor(Date.now() / 1000)],
|
||||
);
|
||||
return { uid, path, name };
|
||||
};
|
||||
|
||||
const share = (owner: TestUserCredentials, uid: string) =>
|
||||
post('/share', owner.token, {
|
||||
recipients: [env.users.other.username],
|
||||
items: [{ uid }],
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
it('turns an unverified owner away, leading with the phone flow', async () => {
|
||||
const owner = await makeUser();
|
||||
const file = await makeFile(owner);
|
||||
const res = await share(owner, file.uid);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({
|
||||
code: 'phone_verification_required',
|
||||
factors: ['phone'],
|
||||
});
|
||||
});
|
||||
|
||||
it('lets an owner with a verified number share', async () => {
|
||||
const owner = await makeUser({ phone: '+14155550123' });
|
||||
const file = await makeFile(owner);
|
||||
const res = await share(owner, file.uid);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ status: 'success' });
|
||||
});
|
||||
|
||||
it('lets a paying owner share without either factor', async () => {
|
||||
const owner = await makeUser();
|
||||
const row = await env.server.stores.user.getByUsername(owner.username);
|
||||
const metering = env.server.services.metering;
|
||||
const real = metering.getActorSubscription.bind(metering);
|
||||
const spy = vi
|
||||
.spyOn(metering, 'getActorSubscription')
|
||||
.mockImplementation(async (actor) =>
|
||||
actor.user?.uuid === row!.uuid
|
||||
? ({ id: 'business' } as never)
|
||||
: real(actor),
|
||||
);
|
||||
try {
|
||||
const file = await makeFile(owner);
|
||||
expect((await share(owner, file.uid)).status).toBe(200);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
metering.invalidateActorSubscription(row!.uuid);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a verified card once an extension reports the card gate on', async () => {
|
||||
const cardGateOn = (
|
||||
_key: string,
|
||||
data: { enabled: boolean | null },
|
||||
) => {
|
||||
data.enabled = true;
|
||||
};
|
||||
resetCardVerificationStatusCache();
|
||||
env.server.clients.event.on(
|
||||
'puter.card-verification.status',
|
||||
cardGateOn,
|
||||
);
|
||||
try {
|
||||
const carded = await makeUser({ card_fingerprint: 'fp_test' });
|
||||
const file = await makeFile(carded);
|
||||
expect((await share(carded, file.uid)).status).toBe(200);
|
||||
|
||||
// ...and an owner with neither is now offered both.
|
||||
const bare = await makeUser();
|
||||
const bareFile = await makeFile(bare);
|
||||
const res = await share(bare, bareFile.uid);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({
|
||||
code: 'phone_verification_required',
|
||||
factors: ['phone', 'card'],
|
||||
});
|
||||
} finally {
|
||||
env.server.clients.event.off(
|
||||
'puter.card-verification.status',
|
||||
cardGateOn,
|
||||
);
|
||||
resetCardVerificationStatusCache();
|
||||
}
|
||||
});
|
||||
|
||||
it('never gates withdrawing or listing shares', async () => {
|
||||
const owner = await makeUser();
|
||||
const file = await makeFile(owner);
|
||||
const revoke = await post('/share/revoke', owner.token, {
|
||||
recipients: [env.users.other.username],
|
||||
items: [{ uid: file.uid }],
|
||||
});
|
||||
expect(revoke.status).toBe(200);
|
||||
const list = await get('/share/shared-by-me', owner.token);
|
||||
expect(list.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,8 @@ export async function toClientShare(
|
||||
...(share.pending
|
||||
? { pending: true, recipient_email: share.recipientEmail }
|
||||
: {}),
|
||||
// A link share: no holder of any kind, this is what says so.
|
||||
...(share.anyone ? { anyone: true } : {}),
|
||||
// Set on a share call only, so a listing stays silent about it.
|
||||
...(share.isNew === undefined ? {} : { is_new: share.isNew }),
|
||||
uid_entry: share.entryUid,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 { RouteRateLimit } from '../../core/http/types';
|
||||
|
||||
// Shared like `fs/limits.ts` is shared: the scope only ties counters
|
||||
// together if every caller passes the same spec.
|
||||
|
||||
/**
|
||||
* Two windows: a burst ceiling, and a daily one so a slow drip can't add up to
|
||||
* a mail-merge. Neither bounds _shares_ — one request carries many — which is
|
||||
* what `ShareService`'s per-day quota is for.
|
||||
*/
|
||||
export const SHARE_LIMIT: RouteRateLimit[] = [
|
||||
{ scope: 'share:mutate', limit: 60, window: 60_000, key: 'user' },
|
||||
{
|
||||
scope: 'share:mutate-daily',
|
||||
limit: 500,
|
||||
window: 24 * 60 * 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* One bucket for every share-listing read. The `/share` routes gate on it, and
|
||||
* `stat` charges it imperatively when `return_shares` asks for the same listing
|
||||
* work — otherwise the flag runs share listings under `fs:stat`'s far more
|
||||
* generous budget.
|
||||
*/
|
||||
export const SHARE_LIST_LIMIT: RouteRateLimit = {
|
||||
scope: 'share:list',
|
||||
limit: 600,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
};
|
||||
@@ -235,6 +235,52 @@ describe('team endpoints over HTTP', () => {
|
||||
expect(member?.org_owned).toBe(true);
|
||||
});
|
||||
|
||||
it('gives seat uuids to the owner and to nobody else', async () => {
|
||||
// Built through the service, not the wire: provisioning shares a
|
||||
// rate-limit budget with the seat tests below, and one more HTTP
|
||||
// provision here 429s them.
|
||||
const owner = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!;
|
||||
const slug0 = Math.random().toString(36).slice(2, 9);
|
||||
const team = await env.server.services.team.createTeam(owner.id, {
|
||||
name: 'Acme',
|
||||
handle: `uuidt-${slug0}`,
|
||||
});
|
||||
const memberUsername = `seat_${slug0}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, owner.id, {
|
||||
username: memberUsername,
|
||||
});
|
||||
|
||||
const owned = (await (
|
||||
await call('GET', `/teams/${team.uid}/members`, env.users.user.token)
|
||||
).json()) as { items: { username: string; uuid?: string }[] };
|
||||
const seat = owned.items.find((m) => m.username === memberUsername);
|
||||
// Billing keys a seat's plan on this, so the owner cannot act without it.
|
||||
expect(seat?.uuid).toEqual(expect.any(String));
|
||||
|
||||
// A throwaway member, not the shared fixture: joining a team is
|
||||
// permanent and would follow `other` into every later test.
|
||||
const slug = Math.random().toString(36).slice(2, 9);
|
||||
const joiner = await env.server.stores.user.create({
|
||||
username: `joiner_${slug}`,
|
||||
uuid: crypto.randomUUID(),
|
||||
password: 'hashed',
|
||||
email: `joiner_${slug}@test.local`,
|
||||
});
|
||||
await env.server.stores.team.addMember(team.uid, joiner.id, {
|
||||
orgOwned: false,
|
||||
});
|
||||
const { token } =
|
||||
await env.server.services.auth.createSessionToken(joiner);
|
||||
|
||||
const seen = (await (
|
||||
await call('GET', `/teams/${team.uid}/members`, token)
|
||||
).json()) as { items: { uuid?: string }[] };
|
||||
expect(seen.items.length).toBeGreaterThan(0);
|
||||
for (const m of seen.items) expect(m.uuid).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- provisioning over the wire -----------------------------------
|
||||
|
||||
it('never returns the activation link to the administrator', async () => {
|
||||
|
||||
@@ -85,6 +85,7 @@ export class TeamController extends PuterController {
|
||||
})
|
||||
async createTeam(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.#requireTeamsAvailable(req, userId);
|
||||
const body = this.#body(req);
|
||||
|
||||
const team = await this.services.team.createTeam(userId, {
|
||||
@@ -105,6 +106,7 @@ export class TeamController extends PuterController {
|
||||
})
|
||||
async listTeams(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.#requireTeamsAvailable(req, userId);
|
||||
const teams = await this.stores.team.listTeamsForUser(userId);
|
||||
res.json({
|
||||
items: teams.map((t) =>
|
||||
@@ -182,10 +184,13 @@ export class TeamController extends PuterController {
|
||||
})
|
||||
async listMembers(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.services.team.requireMembership(
|
||||
const team = await this.services.team.requireMembership(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
);
|
||||
// Only the owner: the uuid is what billing keys a seat's plan on, and
|
||||
// one member has no business identifying another.
|
||||
const isOwner = team.owner_user_id === userId;
|
||||
|
||||
const page = await this.stores.team.listMembers(
|
||||
this.#param(req, 'uid'),
|
||||
@@ -202,6 +207,7 @@ export class TeamController extends PuterController {
|
||||
username: m.username,
|
||||
org_owned: Number(m.org_owned) === 1,
|
||||
created_at: m.created_at,
|
||||
...(isOwner ? { uuid: m.uuid } : {}),
|
||||
})),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
});
|
||||
@@ -249,7 +255,7 @@ export class TeamController extends PuterController {
|
||||
const body = this.#body(req);
|
||||
const result = await this.services.team.provisionAccount(uid, userId, {
|
||||
username: this.#requireString(body.username, 'username'),
|
||||
email: this.#requireString(body.email, 'email'),
|
||||
email: this.#optionalString(body.email, 'email'),
|
||||
});
|
||||
// Shown once; the admin delivers it out of band.
|
||||
res.json({
|
||||
@@ -349,8 +355,8 @@ export class TeamController extends PuterController {
|
||||
async deleteMember(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
// Authority first (anti-oracle); deleted team included, see the service.
|
||||
await this.services.team.requireOwnedTeam(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.deleteMember(uid, userId, target);
|
||||
@@ -406,6 +412,18 @@ export class TeamController extends PuterController {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The domain-allowlist gate, on the two routes that enter the feature. Same
|
||||
* 404 as a teams-off deployment; other routes bound by membership.
|
||||
*/
|
||||
async #requireTeamsAvailable(req: Request, userId: number): Promise<void> {
|
||||
const email = (
|
||||
req.actor as { user?: { email?: string | null } } | undefined
|
||||
)?.user?.email;
|
||||
if (await this.services.team.teamsAvailableTo(userId, email)) return;
|
||||
throw new HttpError(404, 'Not found', { legacyCode: 'not_found' });
|
||||
}
|
||||
|
||||
#requireUserId(req: Request): number {
|
||||
const id = (req.actor as { user?: { id?: number } } | undefined)?.user
|
||||
?.id;
|
||||
@@ -426,6 +444,17 @@ export class TeamController extends PuterController {
|
||||
return (req.body ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Absent or empty means "not given"; a wrong type is still a 400. */
|
||||
#optionalString(value: unknown, field: string): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value !== 'string') {
|
||||
throw new HttpError(400, `${field} must be a string`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return value.trim() === '' ? null : value;
|
||||
}
|
||||
|
||||
#requireString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new HttpError(400, `${field} is required`, {
|
||||
|
||||
@@ -32,24 +32,8 @@ import type {
|
||||
WithControllerRegistration,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Extension-augmentable controller registry. Extensions add their own
|
||||
* controller instance types via TypeScript declaration merging:
|
||||
*
|
||||
* declare module '@heyputer/backend/controllers/types' {
|
||||
* interface IExtensionControllerInstances {
|
||||
* myController: MyController;
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Augmentations flow into the `extension.import('controller')` proxy.
|
||||
*/
|
||||
/** Extension-augmentable controller registry; see `IExtensionClientInstances`. */
|
||||
export interface IExtensionControllerInstances {
|
||||
/**
|
||||
* Open index signature so reads of extension-only controller keys return
|
||||
* `unknown` instead of a type error. Concrete declaration-merged keys
|
||||
* override this for that name.
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -64,11 +48,8 @@ export type IPuterController<
|
||||
) => T;
|
||||
|
||||
/**
|
||||
* Base class for v2 controllers. `registerRoutes(router)` receives a
|
||||
* `PuterRouter` (not an express app) — see `core/http/PuterRouter.ts`.
|
||||
* Controllers either override `registerRoutes` imperatively or lean on the
|
||||
* `@Controller` / `@Post` / etc. decorators, which install a default
|
||||
* `registerRoutes` walker on the prototype.
|
||||
* Base class for controllers. Override `registerRoutes(router)` or use the
|
||||
* `@Controller` / `@Post` decorators, which install a default one.
|
||||
*/
|
||||
export const PuterController =
|
||||
class PuterController implements WithControllerRegistration {
|
||||
|
||||
+41
-44
@@ -25,58 +25,43 @@ export interface ActorApp {
|
||||
}
|
||||
|
||||
/**
|
||||
* Access-token wrapper. When set, this actor is acting _through_ an access
|
||||
* token issued by `issuer`. The token's row in `access_token_permissions` gates
|
||||
* which permissions of the issuer it can exercise.
|
||||
* Set when the actor acts through an access token issued by `issuer`; the
|
||||
* token's `access_token_permissions` rows gate which of the issuer's
|
||||
* permissions it may exercise.
|
||||
*/
|
||||
export interface ActorAccessToken {
|
||||
uid: string;
|
||||
issuer: Actor;
|
||||
authorized?: Actor | null;
|
||||
/**
|
||||
* Full-API-access ("personal access token") flag, set from the signed
|
||||
* `full_access` JWT claim. Such a token may exercise everything its issuing
|
||||
* user can do via the API and is admitted past `requireNonAccessTokenGate`
|
||||
* — but is still rejected by `requireUserActor` / web-session gates, so it
|
||||
* can never manage the account. Normal (scoped) access tokens leave this
|
||||
* false and remain blocked from non-`allowAccessToken` routes.
|
||||
* Personal access token (signed `full_access` claim): full API reach, but
|
||||
* still rejected by `requireUserActor`, so never account management.
|
||||
*/
|
||||
fullAccess?: boolean;
|
||||
}
|
||||
|
||||
export interface Actor {
|
||||
user: Partial<UserRow>;
|
||||
/**
|
||||
* The app this actor carries _directly_, empty on an access token an app
|
||||
* issued. Answers "is this an app acting as itself" — `isAppActor` is
|
||||
* usually the clearer way to ask — and never "which app is acting", which
|
||||
* reads as "no app" for a token and falls open.
|
||||
*/
|
||||
app?: ActorApp | null;
|
||||
/**
|
||||
* The app this actor ultimately acts as: its own `app`, or failing that the
|
||||
* app of whoever issued its access token.
|
||||
*
|
||||
* Read this — not `app` — in any gate asking "which app is doing this?". An
|
||||
* access-token actor carries no `app` of its own, so `app` alone reads as
|
||||
* "no app" even for a token an app minted, and a gate keyed off it fails
|
||||
* open exactly where it must not.
|
||||
*
|
||||
* `null` and `undefined` are not the same thing here:
|
||||
*
|
||||
* - `null` — resolved, and this actor is not acting as any app.
|
||||
* - Absent — never resolved, because the actor skipped `makeActor`.
|
||||
*
|
||||
* A gate must not read the second as the first: that is the fail-open this
|
||||
* field exists to prevent. Optional only so an actor literal that predates
|
||||
* the field still compiles; `assertResolvedActor` is what keeps the request
|
||||
* path honest, and `makeActor` is the one place the derivation lives.
|
||||
* The app this actor ultimately acts as: its own `app`, else the app of its
|
||||
* access token's issuer. Gates asking "which app" must read this, not
|
||||
* `app`. `null` means resolved to no app; `undefined` means the actor
|
||||
* skipped `makeActor` and must not be read as "no app".
|
||||
*/
|
||||
effectiveApp?: ActorApp | null;
|
||||
/** True for the system actor; skips metering / quota tracking. */
|
||||
system?: boolean;
|
||||
accessToken?: ActorAccessToken | null;
|
||||
/**
|
||||
* Session reference when authenticated via a session token (user actors) or
|
||||
* an app-under-user token that carries a session. Absent for system,
|
||||
* raw-app, and pure access-token actors. Used for session introspection and
|
||||
* targeted logout. `kind` mirrors the session row's kind (e.g. 'web',
|
||||
* 'app', 'worker') so callers can gate on how the credential was minted
|
||||
* without an extra session lookup.
|
||||
* Set when authenticated by a session token or an app-under-user token
|
||||
* carrying one. `kind` mirrors the session row (`web`, `app`, `worker`).
|
||||
*/
|
||||
session?: { uid: string; kind?: string | null } | null;
|
||||
}
|
||||
@@ -92,11 +77,8 @@ export const SYSTEM_ACTOR: Actor = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an actor, deriving `effectiveApp` from its own app and, failing that,
|
||||
* from the app of whoever issued its access token.
|
||||
*
|
||||
* The issuer was itself built here, so its chain is already collapsed — one hop
|
||||
* is enough, and no caller has to walk anything.
|
||||
* Build an actor with `effectiveApp` derived; the issuer is already collapsed,
|
||||
* so one hop suffices.
|
||||
*/
|
||||
export const makeActor = (actor: Omit<Actor, 'effectiveApp'>): Actor => ({
|
||||
...actor,
|
||||
@@ -104,13 +86,8 @@ export const makeActor = (actor: Omit<Actor, 'effectiveApp'>): Actor => ({
|
||||
});
|
||||
|
||||
/**
|
||||
* Fail closed on an actor whose `effectiveApp` was never derived.
|
||||
*
|
||||
* Every actor on the request path is built by `AuthService` through
|
||||
* `makeActor`, so this cannot fire in production — which is the point. It turns
|
||||
* a future actor literal that skips the builder into a loud 500 at the edge
|
||||
* rather than a silent bypass deep inside a gate that read `undefined` as "no
|
||||
* app". Call it once, where the request actor is established.
|
||||
* Fail closed on an actor that skipped `makeActor`, at the edge rather than
|
||||
* inside a gate reading `undefined` as "no app".
|
||||
*/
|
||||
export const assertResolvedActor = (actor: Actor): Actor => {
|
||||
if (actor.effectiveApp === undefined) {
|
||||
@@ -130,12 +107,32 @@ export const isAppActor = (actor: Actor | undefined | null): boolean => {
|
||||
return !!actor?.app && !isAccessTokenActor(actor);
|
||||
};
|
||||
|
||||
/**
|
||||
* The account acting as itself, through nothing: no app and no token of any
|
||||
* kind. Narrower than `isAccountContext`, which also admits a full-access token
|
||||
* — use this where the distinction is "a browser session" rather than "the
|
||||
* account's own reach".
|
||||
*/
|
||||
export const isPlainUserActor = (
|
||||
actor: Pick<Actor, 'app' | 'accessToken'> | undefined | null,
|
||||
): boolean => !!actor && !actor.app && !actor.accessToken;
|
||||
|
||||
export const isAccessTokenActor = (
|
||||
actor: Actor | undefined | null,
|
||||
): boolean => {
|
||||
return !!actor?.accessToken;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the actor holds the account's own reach: a plain session or a
|
||||
* full-access token. Use this, not `effectiveApp === null`, which would also
|
||||
* admit scoped access tokens. Unresolved actors answer no.
|
||||
*/
|
||||
export const isAccountContext = (actor: Actor | undefined | null): boolean => {
|
||||
if (!actor || actor.effectiveApp !== null) return false;
|
||||
return !actor.accessToken || actor.accessToken.fullAccess === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable identifier for an actor. Used as a cache key (e.g., permission scan
|
||||
* cache) and for cycle detection.
|
||||
|
||||
@@ -59,23 +59,14 @@ const getOrInitRoutes = (proto: DecoratedPrototype): CollectedRoute[] => {
|
||||
|
||||
// -- @Controller -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Class decorator.
|
||||
*
|
||||
* - Stores the controller's path `prefix` on the prototype so `PuterServer` can
|
||||
* construct a correctly-prefixed `PuterRouter` for this controller.
|
||||
* - Installs a default `registerRoutes(router)` on the prototype that walks
|
||||
* routes collected by method decorators (if this class hasn't defined its own
|
||||
* `registerRoutes`). This means a purely-decorated controller needs no body —
|
||||
* the decorators do all the wiring.
|
||||
*
|
||||
* Controllers that define their own `registerRoutes` are untouched; they can
|
||||
* still use `@Post` etc. and walk `prototype[ROUTES_METADATA_KEY]` manually if
|
||||
* they want to combine the styles.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyCtor = new (...args: any[]) => any;
|
||||
|
||||
/**
|
||||
* Class decorator. Stores `prefix` on the prototype for `PuterServer`, and
|
||||
* installs a default `registerRoutes` that walks decorator-collected routes
|
||||
* unless the class defines its own.
|
||||
*/
|
||||
export function Controller(prefix: string = '') {
|
||||
return <T extends AnyCtor>(
|
||||
value: T,
|
||||
@@ -84,9 +75,7 @@ export function Controller(prefix: string = '') {
|
||||
const proto = value.prototype as DecoratedPrototype;
|
||||
proto[PREFIX_METADATA_KEY] = prefix;
|
||||
|
||||
// Only install the default walker if the class itself hasn't
|
||||
// defined registerRoutes. We check *own* properties (not inherited)
|
||||
// so a PuterController base-class default doesn't block us.
|
||||
// Own property only, so a base-class default doesn't block the walker.
|
||||
const hasOwnRegister = Object.prototype.hasOwnProperty.call(
|
||||
proto,
|
||||
'registerRoutes',
|
||||
|
||||
@@ -61,10 +61,8 @@ declare global {
|
||||
reason: 'session_revoked' | 'session_expired';
|
||||
auth_id?: string;
|
||||
/**
|
||||
* Short-lived server-signed JWT that proves the bearer was
|
||||
* identified as `auth_id` by the rejected session. The GUI must
|
||||
* echo this back (not the raw `auth_id`) on the next
|
||||
* login/signup so the controller can rebind to the same user.
|
||||
* Short-lived JWT proving the bearer was `auth_id`; the GUI
|
||||
* echoes it on the next login to rebind.
|
||||
*/
|
||||
reauth_token?: string;
|
||||
};
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { resetCardVerificationStatusCache } from '../../../util/cardFallback';
|
||||
import { makeActor, type Actor } from '../../actor';
|
||||
import { HttpError, isHttpError } from '../HttpError';
|
||||
import {
|
||||
@@ -27,6 +28,8 @@ import {
|
||||
allowedAppIdsGate,
|
||||
assertNotUserSession,
|
||||
noUserSessionGate,
|
||||
requireAnyVerifiedGate,
|
||||
type AnyVerifiedDeps,
|
||||
requireAuthGate,
|
||||
requireCardVerifiedGate,
|
||||
requirePhoneVerifiedGate,
|
||||
@@ -88,6 +91,23 @@ const runGate = (
|
||||
return captured;
|
||||
};
|
||||
|
||||
/** `runGate` for a gate that answers asynchronously. */
|
||||
const runGateAsync = (
|
||||
gate: (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: (arg?: unknown) => void,
|
||||
) => unknown,
|
||||
req: Partial<Request>,
|
||||
): Promise<NextArg> => {
|
||||
if (req.actor) req = { ...req, actor: reviveActor(req.actor) };
|
||||
return new Promise((resolve) =>
|
||||
gate(req as Request, {} as Response, (arg?: unknown) =>
|
||||
resolve(arg as NextArg),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const expectHttpError = (got: NextArg, status: number, legacyCode?: string) => {
|
||||
expect(isHttpError(got)).toBe(true);
|
||||
const err = got as HttpError;
|
||||
@@ -815,11 +835,10 @@ describe('allowedAppIdsGate', () => {
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes any access token issued by an app, allowed or not', () => {
|
||||
// The check reads `actor.app`, which an access-token actor never
|
||||
// carries — its app lives on `effectiveApp`, one hop through the
|
||||
// issuer. Both directions pass, which is why an appId-gated route
|
||||
// cannot treat this gate as proof of app identity.
|
||||
it('judges an access token by the app that issued it', () => {
|
||||
// An access-token actor carries no app of its own — its app is one hop
|
||||
// down the chain, through the issuer — so reading the direct `app`
|
||||
// would let a token from any app through an appId-gated route.
|
||||
const gate = allowedAppIdsGate(['app-allowed']);
|
||||
const tokenIssuedBy = (appUid: string) => ({
|
||||
actor: {
|
||||
@@ -834,7 +853,9 @@ describe('allowedAppIdsGate', () => {
|
||||
},
|
||||
});
|
||||
expect(runGate(gate, tokenIssuedBy('app-allowed'))).toBeUndefined();
|
||||
expect(runGate(gate, tokenIssuedBy('app-other'))).toBeUndefined();
|
||||
expect(runGate(gate, tokenIssuedBy('app-other'))).toBeInstanceOf(
|
||||
HttpError,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes when the actor.app.uid is in the allow-list', () => {
|
||||
@@ -947,22 +968,31 @@ describe('requirePhoneVerifiedGate', () => {
|
||||
});
|
||||
|
||||
describe('requireCardVerifiedGate', () => {
|
||||
it('passes a user with a verified card on file', () => {
|
||||
const got = runGate(requireCardVerifiedGate(), {
|
||||
const plan = (paid: boolean) => ({ hasPaidPlan: async () => paid });
|
||||
|
||||
it('passes a user with a verified card on file', async () => {
|
||||
const got = await runGateAsync(requireCardVerifiedGate(plan(false)), {
|
||||
actor: { user: { uuid: 'u-1', card_fingerprint: 'fp_1' } },
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a user who never verified a card', () => {
|
||||
const got = runGate(requireCardVerifiedGate(), {
|
||||
it('passes a paying account as card-verified', async () => {
|
||||
const got = await runGateAsync(requireCardVerifiedGate(plan(true)), {
|
||||
actor: { user: { uuid: 'u-1' } },
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a user who never verified a card', async () => {
|
||||
const got = await runGateAsync(requireCardVerifiedGate(plan(false)), {
|
||||
actor: { user: { uuid: 'u-1' } },
|
||||
});
|
||||
expectHttpError(got, 403, 'card_verification_required');
|
||||
});
|
||||
|
||||
it('rejects a user still carrying the card gate', () => {
|
||||
const got = runGate(requireCardVerifiedGate(), {
|
||||
it('rejects a user still carrying the card gate', async () => {
|
||||
const got = await runGateAsync(requireCardVerifiedGate(plan(false)), {
|
||||
actor: {
|
||||
user: {
|
||||
uuid: 'u-1',
|
||||
@@ -974,3 +1004,137 @@ describe('requireCardVerifiedGate', () => {
|
||||
expectHttpError(got, 403, 'card_verification_required');
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireAnyVerifiedGate ──────────────────────────────────────────
|
||||
|
||||
describe('requireAnyVerifiedGate', () => {
|
||||
// The card probe is memoized module-wide; every case starts cold.
|
||||
beforeEach(() => resetCardVerificationStatusCache());
|
||||
|
||||
const deps = (
|
||||
sms: boolean,
|
||||
card: boolean | null,
|
||||
paid = false,
|
||||
): AnyVerifiedDeps => ({
|
||||
smsConfigured: () => sms,
|
||||
probeCardVerification: async () => card,
|
||||
hasPaidPlan: async () => paid,
|
||||
});
|
||||
|
||||
const both = ['phone', 'card'] as const;
|
||||
|
||||
it('is inert where neither factor can be verified', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(false, null)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes a verified number whatever the deployment can do today', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(false, null)),
|
||||
{ actor: { user: { uuid: 'u-1', phone: '+15550000000' } } },
|
||||
);
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes a verified card', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, true)),
|
||||
{ actor: { user: { uuid: 'u-1', card_fingerprint: 'fp_1' } } },
|
||||
);
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes a paid plan as the card, verified by other means', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, true, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not let a paid plan stand in for a phone', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(['phone'], deps(true, true, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
expectHttpError(got, 403, 'phone_verification_required');
|
||||
});
|
||||
|
||||
it('asks about the plan only once the row itself has not answered', async () => {
|
||||
let asked = 0;
|
||||
const counting: AnyVerifiedDeps = {
|
||||
...deps(true, true),
|
||||
hasPaidPlan: async () => {
|
||||
asked++;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
await runGateAsync(requireAnyVerifiedGate(both, counting), {
|
||||
actor: { user: { uuid: 'u-1', phone: '+15550000000' } },
|
||||
});
|
||||
expect(asked).toBe(0);
|
||||
await runGateAsync(requireAnyVerifiedGate(both, counting), {
|
||||
actor: { user: { uuid: 'u-1' } },
|
||||
});
|
||||
expect(asked).toBe(1);
|
||||
});
|
||||
|
||||
it('leads with the phone flow and names both factors when both are verifiable', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
expectHttpError(got, 403, 'phone_verification_required');
|
||||
expect((got as HttpError).fields).toEqual({
|
||||
factors: ['phone', 'card'],
|
||||
});
|
||||
});
|
||||
|
||||
it('asks only for the factors the deployment can verify', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(false, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
expectHttpError(got, 403, 'card_verification_required');
|
||||
expect((got as HttpError).fields).toEqual({ factors: ['card'] });
|
||||
});
|
||||
|
||||
it('takes the lead factor from the route order', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(['card', 'phone'], deps(true, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
expectHttpError(got, 403, 'card_verification_required');
|
||||
expect((got as HttpError).fields).toEqual({
|
||||
factors: ['card', 'phone'],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not count a factor still mid-verification', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, null)),
|
||||
{
|
||||
actor: {
|
||||
user: {
|
||||
uuid: 'u-1',
|
||||
phone: '+15550000000',
|
||||
requires_phone_verification: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expectHttpError(got, 403, 'phone_verification_required');
|
||||
expect((got as HttpError).fields).toEqual({ factors: ['phone'] });
|
||||
});
|
||||
|
||||
it('rejects when there is no actor at all', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, null)),
|
||||
{},
|
||||
);
|
||||
expectHttpError(got, 403, 'phone_verification_required');
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import type { Actor } from '../../actor';
|
||||
import {
|
||||
isCardVerificationEnabled,
|
||||
type CardFallbackDeps,
|
||||
} from '../../../util/cardFallback';
|
||||
import { isAppActor, isPlainUserActor, type Actor } from '../../actor';
|
||||
import { HttpError } from '../HttpError';
|
||||
import type { AccountGateUser, VerificationFactor } from '../types';
|
||||
import { assertVerifiedEmail } from '../verifiedEmail';
|
||||
|
||||
// Make sure the `Express.Request.actor` augmentation is in scope.
|
||||
@@ -70,14 +75,7 @@ export const subdomainGate = (allowed: string | string[]): RequestHandler => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject anonymous requests with 401. Also reject authenticated-but-suspended
|
||||
* users with 403 — `actor.user.suspended` is populated by `AuthService` from
|
||||
* `UserStore`, so the gate doesn't need its own DB hit.
|
||||
*
|
||||
* Implied by `requireUserActor`, `adminOnly`, and `allowedAppIds`; the
|
||||
* materializer ensures only one copy ends up in the chain.
|
||||
*/
|
||||
/** 401 for anonymous requests, 403 for suspended accounts. */
|
||||
export const requireAuthGate = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
if (req.appBlocked) {
|
||||
@@ -105,17 +103,10 @@ export const requireAuthGate = (): RequestHandler => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject app-under-user and access-token actors with 403. Use on endpoints that
|
||||
* should only be exercised by a human session — settings changes, admin-style
|
||||
* actions on the user's own account.
|
||||
*
|
||||
* `allowFullAccess` (set per-route via the `allowFullAccessToken` route option)
|
||||
* relaxes ONLY the access-token half: a full-access ("personal access token")
|
||||
* actor is admitted, because it represents the user's own full API reach.
|
||||
* Third-party apps are ALWAYS rejected, and scoped access tokens are always
|
||||
* rejected. This opt-in is for user-resource / inference endpoints (AI proxy,
|
||||
* etc.) that use this gate purely to keep apps out — NEVER for account or
|
||||
* security management, which must stay closed to every access token.
|
||||
* Reject app-under-user and access-token actors with 403. `allowFullAccess`
|
||||
* (route option `allowFullAccessToken`) admits full-access personal access
|
||||
* tokens only; apps and scoped tokens are always rejected. Never set it on
|
||||
* account or security management routes.
|
||||
*/
|
||||
export const requireUserActorGate = (
|
||||
opts: { allowFullAccess?: boolean } = {},
|
||||
@@ -127,10 +118,7 @@ export const requireUserActorGate = (
|
||||
next(rejectAuth(req));
|
||||
return;
|
||||
}
|
||||
// Third-party apps are never allowed through this gate.
|
||||
const appBlocked = !!actor.app;
|
||||
// Access tokens are blocked unless the route opted in AND this is a
|
||||
// full-access PAT (the user's own credential). Scoped tokens: blocked.
|
||||
const appBlocked = isAppActor(actor);
|
||||
const tokenBlocked =
|
||||
!!actor.accessToken &&
|
||||
!(opts.allowFullAccess && actor.accessToken.fullAccess);
|
||||
@@ -149,30 +137,18 @@ export const requireUserActorGate = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject bare user-session actors — the "root" credential a browser session (or
|
||||
* `/login`) holds, with no app and no access token in play. Use on API surfaces
|
||||
* that must only be driven by a delegated credential: an app or worker token,
|
||||
* or an API token minted from the dashboard. The point is that a
|
||||
* leaked-or-copied session token (full account control) shouldn't double as an
|
||||
* AI/API credential; users are pushed to mint a revocable token instead.
|
||||
*
|
||||
* This gate only rejects the bare-session shape. Which delegated credentials
|
||||
* are acceptable is decided by the gates it composes with (`requireUserActor`
|
||||
*
|
||||
* - `allowFullAccessToken` to also keep apps out, `requireNonAccessTokenGate` for
|
||||
* scoped tokens, etc.).
|
||||
* Reject bare account-session actors (no app, no access token) so a session
|
||||
* token never doubles as an API credential. Only rejects that shape; which
|
||||
* delegated credentials are acceptable is decided by the gates it composes
|
||||
* with.
|
||||
*/
|
||||
export const assertNotUserSession = (
|
||||
actor: Pick<Actor, 'app' | 'accessToken' | 'session'> | null | undefined,
|
||||
): void => {
|
||||
if (!actor) return; // anonymous requests are the auth gate's problem
|
||||
if (actor.app || actor.accessToken) return;
|
||||
// User-scoped workers (deployed with no app binding) authenticate with
|
||||
// a session-TYPE token whose session row is `kind='worker'` — a managed,
|
||||
// revocable deployment credential, not a browser sign-in. Workers are
|
||||
// never treated as root tokens: this gate is an annoyance for
|
||||
// sign-up-and-scrape abuse, and someone who deploys a worker to reach
|
||||
// an API has already left that path.
|
||||
if (!isPlainUserActor(actor)) return;
|
||||
// App-less workers hold a session-type token with `kind='worker'`: a
|
||||
// revocable deployment credential, not a browser sign-in.
|
||||
if (actor.session?.kind === 'worker') return;
|
||||
throw new HttpError(
|
||||
403,
|
||||
@@ -208,12 +184,8 @@ export const requireNonAccessTokenGate = (): RequestHandler => {
|
||||
next(rejectAuth(req));
|
||||
return;
|
||||
}
|
||||
// Full-access ("personal access token") access tokens are admitted here:
|
||||
// they carry the user's full API reach by design. They remain blocked
|
||||
// from account management because those routes also use
|
||||
// `requireUserActorGate`, which rejects ALL access tokens. Normal
|
||||
// (scoped) access tokens stay blocked from non-`allowAccessToken`
|
||||
// routes.
|
||||
// Full-access tokens pass; `requireUserActorGate` still keeps them
|
||||
// off account management.
|
||||
if (actor.accessToken && !actor.accessToken.fullAccess) {
|
||||
next(
|
||||
new HttpError(
|
||||
@@ -232,33 +204,17 @@ export const requireNonAccessTokenGate = (): RequestHandler => {
|
||||
export const DEFAULT_ADMIN_USERNAMES = ['admin', 'system'] as const;
|
||||
|
||||
/**
|
||||
* Reject unless `actor.user.username` matches `admin`, `system`, or one of the
|
||||
* supplied extras. Extras are _additional_ allowed users on top of the built-in
|
||||
* pair, not a replacement for it.
|
||||
*
|
||||
* Also requires a _root token_ — an actor with no app anywhere in its token
|
||||
* chain (see `Actor.effectiveApp`) — so a third-party app an admin has
|
||||
* authorized can't reach admin endpoints on the admin's behalf. The one
|
||||
* exception is `appGated`: on a route that is also appId-gated
|
||||
* (`allowedAppIds`), a direct app-under-user actor is deferred to
|
||||
* `allowedAppIdsGate`, so the net effect there is "a root token OR a token
|
||||
* scoped to an allowed app". Access tokens issued through an app are rejected
|
||||
* even then — `allowedAppIdsGate` only sees top-level `actor.app` and would
|
||||
* otherwise wave them through.
|
||||
*
|
||||
* Implies `requireAuth`. Does _not_ imply `requireUserActor` — a root token
|
||||
* still includes an admin's full-access personal access token, not only browser
|
||||
* sessions; combine with `requireUserActor` explicitly if a route must be
|
||||
* restricted to browser sessions.
|
||||
* Reject unless the username is `admin`, `system`, or one of `extras`, and the
|
||||
* actor carries no app anywhere in its token chain (`effectiveApp`). With
|
||||
* `appGated` a direct app-under-user actor is deferred to `allowedAppIdsGate`
|
||||
* instead; app-issued access tokens are rejected even then, since that gate
|
||||
* cannot see them. Does not imply `requireUserActor`.
|
||||
*/
|
||||
export const adminOnlyGate = (
|
||||
extras: readonly string[] = [],
|
||||
opts: { appGated?: boolean } = {},
|
||||
): RequestHandler => {
|
||||
// Match the case-insensitivity guarantee of the username column
|
||||
// (MySQL: ascii_general_ci; SQLite: idx_user_username_nocase). Comparing
|
||||
// raw-case here would let a stored `Admin` bypass the lowercase allowlist
|
||||
// on any backend that lets case-collision rows exist.
|
||||
// Match the username column's case-insensitive collation.
|
||||
const allowList = new Set<string>(
|
||||
[...DEFAULT_ADMIN_USERNAMES, ...extras].map((u) => u.toLowerCase()),
|
||||
);
|
||||
@@ -272,13 +228,8 @@ export const adminOnlyGate = (
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Root-token requirement: reject actors carrying an app anywhere in
|
||||
// their token chain — app-under-user, or an access token issued
|
||||
// through an app. A direct app-under-user actor is deferred to
|
||||
// `allowedAppIdsGate` when the route is appId-gated; chain-only apps
|
||||
// are rejected even then, since that gate can't see them.
|
||||
const chainApp = req.actor?.effectiveApp ?? null;
|
||||
if (chainApp && !(opts.appGated && req.actor?.app?.uid)) {
|
||||
if (chainApp && !(opts.appGated && isAppActor(req.actor))) {
|
||||
next(
|
||||
new HttpError(403, 'Only admins may request this resource', {
|
||||
legacyCode: 'forbidden',
|
||||
@@ -291,12 +242,9 @@ export const adminOnlyGate = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject unless the authenticated user has a confirmed email. Gated behind
|
||||
* `strict_email_verification_required` config so self-hosted deployments
|
||||
* without email delivery don't brick their own filesystem routes.
|
||||
*
|
||||
* Reads `req.actor?.user?.email_confirmed`, which is present on both user-only
|
||||
* and app-under-user actors, so it works for either shape.
|
||||
* Reject unless the user's email is confirmed. Inert unless
|
||||
* `strict_email_verification_required` is set, so deployments without email
|
||||
* delivery are not locked out.
|
||||
*/
|
||||
export const requireVerifiedGate = (strictFlag: boolean): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
@@ -311,24 +259,9 @@ export const requireVerifiedGate = (strictFlag: boolean): RequestHandler => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject authenticated users whose account is still pending any signup-time
|
||||
* verification — email confirmation, SMS phone verification, or credit-card
|
||||
* verification. The abuse harness sets the phone/card flags on low-reputation
|
||||
* signups (in place of a hard block), and this gate is what actually keeps
|
||||
* those accounts out of the product until the flag clears: the flags live on
|
||||
* `req.actor.user`, so every authenticated route enforces them, not just the
|
||||
* GUI modal.
|
||||
*
|
||||
* Runs on every authenticated route by default; routes that set
|
||||
* `allowUnconfirmed: true` opt out (the verification endpoints themselves, plus
|
||||
* essential flows like whoami / logout / save-account so a pending account can
|
||||
* still reach the screens that clear the gate).
|
||||
*
|
||||
* Returns 403 with a per-gate legacy code (`email_confirmation_required` /
|
||||
* `phone_verification_required` / `card_verification_required` /
|
||||
* `password_change_required`) so clients can show the right prompt instead of a
|
||||
* generic error. There is no state where a user should be allowed in with one
|
||||
* verification pending, so any pending gate rejects.
|
||||
* Reject accounts still pending a signup-time verification (email, phone, card,
|
||||
* password change). Default-on for authenticated routes; the flows that clear
|
||||
* these flags opt out with `allowUnconfirmed: true`.
|
||||
*/
|
||||
export const requireVerifiedAccount = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
@@ -343,31 +276,12 @@ export const requireVerifiedAccount = (): RequestHandler => {
|
||||
};
|
||||
|
||||
/**
|
||||
* The pending-verification check, factored out of {@link requireVerifiedAccount}
|
||||
* so auth paths that build their own actor outside the route-option machinery
|
||||
* can enforce the exact same gate. The WebDAV controller is the motivating
|
||||
* case: it dispatches every method off a single `router.use`, so
|
||||
* `requireVerifiedAccount` is never wired into its chain — it has to call this
|
||||
* directly. Keeping one implementation is the point: a verification gate added
|
||||
* here is picked up by every caller, so the paths can't drift (which is how
|
||||
* WebDAV came to bypass the phone/card gate to begin with).
|
||||
*
|
||||
* Throws 403 with a per-gate legacy code (`email_confirmation_required` /
|
||||
* `phone_verification_required` / `card_verification_required` /
|
||||
* `password_change_required`) so clients can show the right prompt instead of a
|
||||
* generic error. There is no state where a user should be let in with any
|
||||
* verification pending, so the first pending gate rejects.
|
||||
* The check behind {@link requireVerifiedAccount}, for auth paths that build
|
||||
* their own actor outside the route-option chain (e.g. WebDAV). Throws 403 with
|
||||
* a per-gate legacy code so clients can show the right prompt.
|
||||
*/
|
||||
export const assertVerifiedAccount = (
|
||||
user:
|
||||
| {
|
||||
requires_email_confirmation?: unknown;
|
||||
email_confirmed?: unknown;
|
||||
requires_phone_verification?: unknown;
|
||||
requires_card_verification?: unknown;
|
||||
requires_password_change?: unknown;
|
||||
}
|
||||
| undefined,
|
||||
user: AccountGateUser | undefined,
|
||||
): void => {
|
||||
if (user?.requires_email_confirmation && !user?.email_confirmed) {
|
||||
throw new HttpError(403, 'Please confirm your email to continue', {
|
||||
@@ -403,32 +317,27 @@ export const assertVerifiedAccount = (
|
||||
|
||||
// -- Per-verification gates ------------------------------------------
|
||||
//
|
||||
// `requireVerifiedAccount` above is the default-on gate: it turns away an
|
||||
// account that is *pending* a verification the abuse harness asked of it, and
|
||||
// says nothing about accounts that were never asked. The two gates below are
|
||||
// the opt-in counterparts of `requireVerified` (email) for the other two
|
||||
// factors: they require the verification to have actually happened, whether or
|
||||
// not this account was ever flagged for it.
|
||||
//
|
||||
// Neither factor has a boolean "verified" column — the pending flag is cleared
|
||||
// on success and the proof is the artifact left behind. `phone` is written only
|
||||
// once a code has been confirmed, and `card_fingerprint` only once a card has
|
||||
// been checked (both are also what `UserAccountService` reads as the
|
||||
// phone-verified / card-verified signals). Pending flag clear AND artifact
|
||||
// present is therefore the honest reading of "verified", and an account still
|
||||
// mid-flow fails on the flag rather than slipping through on a stale artifact.
|
||||
// Opt-in gates requiring a factor to have actually been verified, unlike
|
||||
// `requireVerifiedAccount` which only rejects accounts still pending one.
|
||||
// There is no "verified" column: the proof is the artifact (`phone`,
|
||||
// `card_fingerprint`) plus a cleared pending flag, so an account mid-flow
|
||||
// fails on the flag rather than passing on a stale artifact.
|
||||
|
||||
/**
|
||||
* Reject unless the user has a phone number verified on file. 403
|
||||
* `phone_verification_required`, the same code the pending-verification gate
|
||||
* uses, so a client that already prompts for the SMS flow prompts here too.
|
||||
*/
|
||||
export const hasVerifiedPhone = (user: AccountGateUser | undefined): boolean =>
|
||||
Boolean(user?.phone) && !user?.requires_phone_verification;
|
||||
|
||||
export const hasVerifiedCard = (user: AccountGateUser | undefined): boolean =>
|
||||
Boolean(user?.card_fingerprint) && !user?.requires_card_verification;
|
||||
|
||||
const PHONE_REQUIRED_MESSAGE = 'Please verify your phone number to continue';
|
||||
const CARD_REQUIRED_MESSAGE = 'Please verify your card to continue';
|
||||
|
||||
/** 403 `phone_verification_required` unless a verified phone is on file. */
|
||||
export const assertPhoneVerified = (
|
||||
user:
|
||||
{ phone?: unknown; requires_phone_verification?: unknown } | undefined,
|
||||
user: AccountGateUser | undefined,
|
||||
): void => {
|
||||
if (user?.phone && !user?.requires_phone_verification) return;
|
||||
throw new HttpError(403, 'Please verify your phone number to continue', {
|
||||
if (hasVerifiedPhone(user)) return;
|
||||
throw new HttpError(403, PHONE_REQUIRED_MESSAGE, {
|
||||
legacyCode: 'phone_verification_required',
|
||||
});
|
||||
};
|
||||
@@ -446,38 +355,107 @@ export const requirePhoneVerifiedGate = (): RequestHandler => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject unless the user has a card verified on file. 403
|
||||
* `card_verification_required`, matching the pending-verification gate so the
|
||||
* client shows the card flow it already has.
|
||||
*/
|
||||
export const assertCardVerified = (
|
||||
user:
|
||||
| { card_fingerprint?: unknown; requires_card_verification?: unknown }
|
||||
| undefined,
|
||||
): void => {
|
||||
if (user?.card_fingerprint && !user?.requires_card_verification) return;
|
||||
throw new HttpError(403, 'Please verify your card to continue', {
|
||||
legacyCode: 'card_verification_required',
|
||||
});
|
||||
};
|
||||
export interface AnyVerifiedDeps extends CardFallbackDeps {
|
||||
/** A paid plan implies a card on file, so it counts as card-verified. */
|
||||
hasPaidPlan: (actor: Actor) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Route-option form of {@link assertCardVerified} (`requireCardVerified`). */
|
||||
export const requireCardVerifiedGate = (): RequestHandler => {
|
||||
const hasCardEvidence = async (
|
||||
actor: Actor | undefined,
|
||||
deps: Pick<AnyVerifiedDeps, 'hasPaidPlan'>,
|
||||
): Promise<boolean> =>
|
||||
hasVerifiedCard(actor?.user) ||
|
||||
(actor !== undefined && (await deps.hasPaidPlan(actor)));
|
||||
|
||||
/**
|
||||
* 403 `card_verification_required` unless a card is on file or the plan is
|
||||
* paid.
|
||||
*/
|
||||
export const requireCardVerifiedGate = (
|
||||
deps: Pick<AnyVerifiedDeps, 'hasPaidPlan'>,
|
||||
): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
try {
|
||||
assertCardVerified(req.actor?.user);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
hasCardEvidence(req.actor, deps).then((verified) => {
|
||||
if (verified) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next(
|
||||
new HttpError(403, CARD_REQUIRED_MESSAGE, {
|
||||
legacyCode: 'card_verification_required',
|
||||
}),
|
||||
);
|
||||
}, next);
|
||||
};
|
||||
};
|
||||
|
||||
export const assertNotSuspended = (
|
||||
user: { suspended?: unknown } | undefined,
|
||||
): void => {
|
||||
const isFactorVerified = (
|
||||
factor: VerificationFactor,
|
||||
actor: Actor | undefined,
|
||||
deps: Pick<AnyVerifiedDeps, 'hasPaidPlan'>,
|
||||
): Promise<boolean> =>
|
||||
factor === 'phone'
|
||||
? Promise.resolve(hasVerifiedPhone(actor?.user))
|
||||
: hasCardEvidence(actor, deps);
|
||||
|
||||
/** Whether this deployment can run the factor's verification flow at all. */
|
||||
const isFactorVerifiable = async (
|
||||
factor: VerificationFactor,
|
||||
deps: CardFallbackDeps,
|
||||
): Promise<boolean> =>
|
||||
factor === 'phone'
|
||||
? deps.smsConfigured()
|
||||
: (await isCardVerificationEnabled(deps)) === true;
|
||||
|
||||
/**
|
||||
* Reject unless at least one of `factors` is verified. Only factors this
|
||||
* deployment can verify are asked for, so with none verifiable the gate is
|
||||
* inert. The 403 carries the first verifiable factor's code and lists every
|
||||
* verifiable one in `factors`.
|
||||
*/
|
||||
export const assertAnyVerified = async (
|
||||
actor: Actor | undefined,
|
||||
factors: readonly VerificationFactor[],
|
||||
deps: AnyVerifiedDeps,
|
||||
): Promise<void> => {
|
||||
// In the route's order, so a verified phone never costs the plan lookup.
|
||||
for (const factor of factors) {
|
||||
if (await isFactorVerified(factor, actor, deps)) return;
|
||||
}
|
||||
const verifiable: VerificationFactor[] = [];
|
||||
for (const factor of factors) {
|
||||
if (verifiable.includes(factor)) continue;
|
||||
if (await isFactorVerifiable(factor, deps)) verifiable.push(factor);
|
||||
}
|
||||
if (verifiable.length === 0) return;
|
||||
const [lead] = verifiable;
|
||||
throw new HttpError(
|
||||
403,
|
||||
lead === 'phone' ? PHONE_REQUIRED_MESSAGE : CARD_REQUIRED_MESSAGE,
|
||||
{
|
||||
legacyCode:
|
||||
lead === 'phone'
|
||||
? 'phone_verification_required'
|
||||
: 'card_verification_required',
|
||||
fields: { factors: verifiable },
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/** Route-option form of {@link assertAnyVerified} (`requireAnyVerified`). */
|
||||
export const requireAnyVerifiedGate = (
|
||||
factors: readonly VerificationFactor[],
|
||||
deps: AnyVerifiedDeps,
|
||||
): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
assertAnyVerified(req.actor, factors, deps).then(
|
||||
() => next(),
|
||||
(err) => next(err),
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const assertNotSuspended = (user: AccountGateUser | undefined): void => {
|
||||
if (user?.suspended) {
|
||||
throw new HttpError(403, 'Account suspended', {
|
||||
legacyCode: 'forbidden',
|
||||
@@ -486,33 +464,17 @@ export const assertNotSuspended = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrow WHICH apps may reach a route: an app-under-user actor whose
|
||||
* `actor.app.uid` is missing from the allowList is rejected with 403.
|
||||
*
|
||||
* It is NOT an app gate, despite the name. Every actor with no `app` of its own
|
||||
* passes through — browser sessions, full-access personal access tokens, worker
|
||||
* sessions, and access tokens issued BY an app, since the check reads
|
||||
* `actor.app` and never `actor.effectiveApp`, leaving an app in the token chain
|
||||
* invisible here. Routes depend on that pass-through: `adminOnly` +
|
||||
* `allowedAppIds` means "a root token OR a token scoped to an allowed app", and
|
||||
* the admin and dev-account surfaces are called both ways.
|
||||
*
|
||||
* So use it to keep other apps out, never as proof that an app is present. The
|
||||
* default-on `requireNonAccessTokenGate` is what keeps app-issued tokens off
|
||||
* these routes; a route setting `allowAccessToken` loses that cover and has to
|
||||
* check `effectiveApp` itself.
|
||||
*
|
||||
* Implies `requireAuth`. Pairing it with `requireUserActor` leaves the
|
||||
* allowList dead — that gate rejects every app, and everything it admits passes
|
||||
* here.
|
||||
* Reject actors acting as an app that is not allow-listed — the app they carry
|
||||
* directly, or the one that issued their access token. Actors with no app
|
||||
* anywhere in the chain pass. Use it to keep other apps out, never as proof an
|
||||
* app is present.
|
||||
*/
|
||||
export const allowedAppIdsGate = (
|
||||
allowedAppUids: readonly string[],
|
||||
): RequestHandler => {
|
||||
const allowList = new Set(allowedAppUids);
|
||||
return (req, _res, next) => {
|
||||
// Deliberately `app`, not `effectiveApp`: see the note above.
|
||||
const appUid = req.actor?.app?.uid;
|
||||
const appUid = req.actor?.effectiveApp?.uid;
|
||||
if (appUid && !allowList.has(appUid)) {
|
||||
next(
|
||||
new HttpError(403, 'This app may not request this resource', {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import type { Request } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { makeActor } from '../../actor';
|
||||
import type { AuthService } from '../../../services/auth/AuthService';
|
||||
import { PuterServer } from '../../../server';
|
||||
import { setupTestServer } from '../../../testUtil';
|
||||
@@ -780,7 +781,7 @@ describe('resolvePrivateIdentity', () => {
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores req.actor whose actor.app.uid doesn't match the target app", async () => {
|
||||
it("ignores req.actor whose app doesn't match the target app", async () => {
|
||||
// Cross-app token confusion guard: an app-under-user actor whose
|
||||
// issuing app is NOT the private host's app must not establish
|
||||
// identity here — even though the underlying user may have legit
|
||||
@@ -791,11 +792,11 @@ describe('resolvePrivateIdentity', () => {
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: {
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
app: { uid: `app-attacker-${uuidv4()}` },
|
||||
session: { uid: 'sess-1' },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
@@ -805,6 +806,77 @@ describe('resolvePrivateIdentity', () => {
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores an app-issued access token minted for a different app', async () => {
|
||||
// An access token carries its issuing app one hop down the chain, on
|
||||
// `effectiveApp`, with no `app` of its own. Reading `app` here would
|
||||
// take it for a portable session token and hand app A's token an
|
||||
// identity on app B's private host.
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
app: { uid: `app-attacker-${uuidv4()}` },
|
||||
});
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
session: { uid: 'sess-1' },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: `app-target-${uuidv4()}`,
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts an app-issued access token minted for the target app', async () => {
|
||||
const user = await makeUser();
|
||||
const targetUid = `app-target-${uuidv4()}`;
|
||||
const issuer = makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
app: { uid: targetUid },
|
||||
});
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
session: { uid: 'sess-1' },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: targetUid,
|
||||
});
|
||||
expect(out.source).toBe('session-cookie');
|
||||
expect(out.userUid).toBe(user.uuid);
|
||||
});
|
||||
|
||||
it('still accepts a user-issued access token, which is portable', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
session: { uid: 'sess-1' },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: `app-target-${uuidv4()}`,
|
||||
});
|
||||
expect(out.source).toBe('session-cookie');
|
||||
expect(out.userUid).toBe(user.uuid);
|
||||
});
|
||||
|
||||
it("ignores a bootstrap query-token whose actor.app.uid doesn't match the target app", async () => {
|
||||
// Same guard for the `?puter.auth.token=` bootstrap path. A token
|
||||
// minted via getUserAppToken for the attacker's app carries
|
||||
|
||||
@@ -451,16 +451,21 @@ export async function resolvePrivateIdentity(opts: {
|
||||
* match the host the request is being made against. A token minted for app A —
|
||||
* e.g. when the visitor authorized a third-party app — must not be honored as
|
||||
* identity on app B's private host, even when the underlying user happens to
|
||||
* have entitlement to B. User-only actors (no `actor.app`) are unaffected: a
|
||||
* plain session token is portable by design.
|
||||
* have entitlement to B. User-only actors are unaffected: a plain session token
|
||||
* is portable by design.
|
||||
*
|
||||
* "No app anywhere in the chain" is the portable case, so read `effectiveApp` —
|
||||
* an app-issued access token has no `app` of its own and would otherwise pass
|
||||
* as a plain session on any private host.
|
||||
*/
|
||||
function actorMatchesExpectedApp(
|
||||
actor: Actor,
|
||||
expectedAppUid: string | undefined,
|
||||
): boolean {
|
||||
if (!expectedAppUid) return true;
|
||||
if (!actor.app?.uid) return true;
|
||||
return actor.app.uid === expectedAppUid;
|
||||
const app = actor.effectiveApp;
|
||||
if (!app) return true;
|
||||
return app.uid === expectedAppUid;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { PuterServer } from '../../../server';
|
||||
import { setupTestServer } from '../../../testUtil';
|
||||
import type { IConfig } from '../../../types';
|
||||
import type { Actor } from '../../actor';
|
||||
import { generateDefaultFsentries } from '../../../util/userProvisioning';
|
||||
import { createPuterSiteMiddleware } from './puterSite';
|
||||
|
||||
@@ -1369,3 +1370,233 @@ describe('createPuterSiteMiddleware — __workers/ is never served', () => {
|
||||
expect(piped!.equals(shell)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPuterSiteMiddleware — hosting CSP', () => {
|
||||
const POLICY = "script-src * 'unsafe-inline'; object-src 'none'";
|
||||
|
||||
// Serves one file at the site root and returns the captured response.
|
||||
const serve = async (
|
||||
filename: string,
|
||||
contentType: string,
|
||||
configOverride?: Partial<IConfig>,
|
||||
) => {
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
|
||||
const sub = `csp-${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}/${filename}`,
|
||||
Buffer.from('<p>hi</p>'),
|
||||
contentType,
|
||||
);
|
||||
|
||||
const mw = buildMiddleware(configOverride);
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: `/${filename}`,
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return out;
|
||||
};
|
||||
|
||||
it('sends no CSP header when hosting_csp is unset', async () => {
|
||||
// The default. Hosted sites are third-party apps, so an unconfigured
|
||||
// deployment must not start enforcing a policy on them.
|
||||
const out = await serve('index.html', 'text/html');
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(out.headers['Content-Security-Policy']).toBeUndefined();
|
||||
expect(
|
||||
out.headers['Content-Security-Policy-Report-Only'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('enforces hosting_csp on an active document when configured', async () => {
|
||||
const out = await serve('index.html', 'text/html', {
|
||||
hosting_csp: POLICY,
|
||||
});
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(out.headers['Content-Security-Policy']).toBe(POLICY);
|
||||
expect(
|
||||
out.headers['Content-Security-Policy-Report-Only'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends the policy as Report-Only when hosting_csp_report_only is set', async () => {
|
||||
const out = await serve('index.html', 'text/html', {
|
||||
hosting_csp: POLICY,
|
||||
hosting_csp_report_only: true,
|
||||
});
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(out.headers['Content-Security-Policy-Report-Only']).toBe(POLICY);
|
||||
expect(out.headers['Content-Security-Policy']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves non-active documents alone', async () => {
|
||||
// A CSP does nothing for a stylesheet or an image, and every header
|
||||
// set here ships to apps we don't control.
|
||||
const out = await serve('style.css', 'text/css', {
|
||||
hosting_csp: POLICY,
|
||||
});
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(out.headers['Content-Security-Policy']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// -- Sites rooted in someone else's directory ------------------------
|
||||
//
|
||||
// Hosting serves everything under the site root with the ACL bypassed, so a
|
||||
// site published from a shared folder rides on the `manage` grant that
|
||||
// authorized it. These pin that the grant is re-read on every request, and
|
||||
// that the ordinary owner-rooted site pays nothing for it.
|
||||
|
||||
const actorFor = (user: {
|
||||
id: number;
|
||||
uuid: string;
|
||||
username: string;
|
||||
}): Actor =>
|
||||
({
|
||||
user: { id: user.id, uuid: user.uuid, username: user.username },
|
||||
effectiveApp: null,
|
||||
}) as Actor;
|
||||
|
||||
const descriptorFor = (path: string) => ({
|
||||
path,
|
||||
resolveAncestors: () => server.services.fs.getAncestorChain(path),
|
||||
});
|
||||
|
||||
const serveSite = async (sub: string) => {
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/index.html',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
// Allow the piped stream to flush.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('createPuterSiteMiddleware — delegated site roots', () => {
|
||||
// Owner shares a directory at `manage`; the delegate points a subdomain
|
||||
// at it, which is exactly what `SubdomainDriver` permits.
|
||||
const publishSharedDir = async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
const delegate = await makeUserWithHome();
|
||||
const dirPath = `/${owner.username}/Documents`;
|
||||
const dirEntry = (await server.stores.fsEntry.getEntryByPath(dirPath))!;
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${dirPath}/index.html`,
|
||||
Buffer.from('<html>shared</html>'),
|
||||
'text/html',
|
||||
);
|
||||
await server.services.acl.setUserUser(
|
||||
actorFor(owner),
|
||||
actorFor(delegate),
|
||||
descriptorFor(dirPath),
|
||||
'manage',
|
||||
);
|
||||
const sub = `shared-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: delegate.id,
|
||||
subdomain: sub,
|
||||
rootDirId: dirEntry.id,
|
||||
});
|
||||
return { owner, delegate, dirEntry, sub };
|
||||
};
|
||||
|
||||
it('serves a site whose publisher still holds `manage` on the root', async () => {
|
||||
const { sub } = await publishSharedDir();
|
||||
const out = await serveSite(sub);
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect((out.body as Buffer).toString()).toBe('<html>shared</html>');
|
||||
});
|
||||
|
||||
it("stops serving once the owner withdraws the publisher's `manage` grant", async () => {
|
||||
const { owner, delegate, dirEntry, sub } = await publishSharedDir();
|
||||
expect((await serveSite(sub)).statusCode).toBe(200);
|
||||
|
||||
await server.services.permission.revokeUserUserPermission(
|
||||
actorFor(owner),
|
||||
delegate.username,
|
||||
`manage:fs:${dirEntry.uuid}`,
|
||||
);
|
||||
|
||||
const out = await serveSite(sub);
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(out.contentType).toBe('text/html; charset=UTF-8');
|
||||
expect(String(out.body)).toContain('404');
|
||||
});
|
||||
|
||||
it('stops serving once the owner unshares the directory', async () => {
|
||||
const { owner, delegate, dirEntry, sub } = await publishSharedDir();
|
||||
expect((await serveSite(sub)).statusCode).toBe(200);
|
||||
|
||||
await server.services.share.unshare(actorFor(owner), {
|
||||
uid: dirEntry.uuid,
|
||||
recipient: { username: delegate.username },
|
||||
});
|
||||
|
||||
expect((await serveSite(sub)).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('stops serving once the owner moves the root into their Trash', async () => {
|
||||
// The grant is keyed on the node, so it survives the move — the
|
||||
// trashed location is what makes the site unservable.
|
||||
const { owner, dirEntry, sub } = await publishSharedDir();
|
||||
expect((await serveSite(sub)).statusCode).toBe(200);
|
||||
|
||||
const trash = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}/Trash`,
|
||||
))!;
|
||||
await server.services.fs.move(owner.id, {
|
||||
source: dirEntry,
|
||||
destinationParent: trash,
|
||||
});
|
||||
|
||||
expect((await serveSite(sub)).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("never consults the ACL for a site rooted in the publisher's own tree", async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
const homeEntry =
|
||||
(await server.stores.fsEntry.getEntryByPath(homePath))!;
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/index.html`,
|
||||
Buffer.from('<html>mine</html>'),
|
||||
'text/html',
|
||||
);
|
||||
const sub = `own-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
rootDirId: homeEntry.id,
|
||||
});
|
||||
|
||||
const aclCheck = vi.spyOn(server.services.acl, 'check');
|
||||
try {
|
||||
const out = await serveSite(sub);
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(aclCheck).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
aclCheck.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,8 +22,10 @@ import { contentType as contentTypeFromMime } from 'mime-types';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import type { puterClients } from '../../../clients';
|
||||
import type { puterServices } from '../../../services';
|
||||
import { MANAGE_PERM_PREFIX } from '../../../services/permission/consts';
|
||||
import type { puterStores } from '../../../stores';
|
||||
import type { IConfig, LayerInstances } from '../../../types';
|
||||
import { makeActor } from '../../actor';
|
||||
import {
|
||||
buildAppCenterFallback,
|
||||
buildHostingConfig,
|
||||
@@ -89,6 +91,32 @@ const isWorkersSourcePath = (urlPath: string): boolean =>
|
||||
.split('/')
|
||||
.some((segment) => segment.toLowerCase() === WORKERS_FOLDER);
|
||||
|
||||
/** Inside some owner's top-level Trash, which is where Delete puts things. */
|
||||
const isTrashedPath = (path: string): boolean =>
|
||||
/^\/[^/]+\/Trash(\/|$)/u.test(path);
|
||||
|
||||
/**
|
||||
* Suggested value for `config.hosting_csp`. Not applied unless configured.
|
||||
*
|
||||
* Sources stay wide open because hosted sites legitimately pull from anywhere,
|
||||
* and `'unsafe-inline'` stays because most hosted apps use inline script. What
|
||||
* it withholds is `'unsafe-eval'` — the capability a stale library needs to
|
||||
* reach `new Function`, which is how CVE-2024-4367 turns a crafted PDF into
|
||||
* script execution in the viewer app's own origin.
|
||||
*
|
||||
* Apps that genuinely need eval (some wasm glue, some template engines) will
|
||||
* break under this, which is why it ships as a suggestion rather than a
|
||||
* default. Roll it out with `hosting_csp_report_only` first and read the
|
||||
* violation reports before enforcing.
|
||||
*/
|
||||
export const RECOMMENDED_HOSTING_CSP = [
|
||||
'default-src * data: blob:',
|
||||
"script-src * data: blob: 'unsafe-inline'",
|
||||
"style-src * data: blob: 'unsafe-inline'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
].join('; ');
|
||||
|
||||
const SUBDOMAIN_404 = `<div style="font-size: 20px;
|
||||
text-align: center;
|
||||
height: calc(100vh);
|
||||
@@ -460,6 +488,49 @@ export const createPuterSiteMiddleware = (
|
||||
return;
|
||||
}
|
||||
|
||||
// A site rooted in someone else's directory stands on the `manage`
|
||||
// grant that authorized publishing it, and serves that whole subtree
|
||||
// with the ACL bypassed — so the grant is re-read on every request.
|
||||
// Trash is its own check: a grant is keyed on the node, not its
|
||||
// location, so it survives the owner deleting the directory. Sites
|
||||
// rooted in their publisher's own tree skip all of this.
|
||||
if (rootEntry.userId !== site.user_id) {
|
||||
let stillPublishable = false;
|
||||
if (!isTrashedPath(rootEntry.path)) {
|
||||
try {
|
||||
stillPublishable = await layers.services.acl.check(
|
||||
makeActor({
|
||||
user: {
|
||||
id: owner.id,
|
||||
uuid: owner.uuid,
|
||||
username: owner.username,
|
||||
},
|
||||
}),
|
||||
{
|
||||
path: rootEntry.path,
|
||||
resolveAncestors: () =>
|
||||
layers.services.fs.getAncestorChain(
|
||||
rootEntry.path,
|
||||
),
|
||||
},
|
||||
MANAGE_PERM_PREFIX,
|
||||
);
|
||||
} catch (e) {
|
||||
// Fail closed — an unreadable grant is not a held one.
|
||||
console.warn(
|
||||
'[puter-site] publish grant recheck failed',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!stillPublishable) {
|
||||
res.status(404)
|
||||
.type('text/html; charset=UTF-8')
|
||||
.send(SUBDOMAIN_404);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve URL path → absolute FS path under the site root.
|
||||
let urlPath = req.path || '/';
|
||||
if (urlPath.endsWith('/')) urlPath += 'index.html';
|
||||
@@ -587,7 +658,23 @@ export const createPuterSiteMiddleware = (
|
||||
|
||||
// Fire-and-forget signal for downstream extensions
|
||||
const mimeBase = mime.split(';', 1)[0].trim().toLowerCase();
|
||||
if (mimeBase === 'text/html' || mimeBase === 'application/xhtml+xml') {
|
||||
const isActiveDocument =
|
||||
mimeBase === 'text/html' || mimeBase === 'application/xhtml+xml';
|
||||
|
||||
// Only active documents carry a CSP — it does nothing for an image or
|
||||
// a stylesheet, and every header we set here ships to third-party apps
|
||||
// we don't control. Off unless `hosting_csp` is configured; see
|
||||
// RECOMMENDED_HOSTING_CSP.
|
||||
if (config.hosting_csp && isActiveDocument) {
|
||||
res.setHeader(
|
||||
config.hosting_csp_report_only
|
||||
? 'Content-Security-Policy-Report-Only'
|
||||
: 'Content-Security-Policy',
|
||||
config.hosting_csp,
|
||||
);
|
||||
}
|
||||
|
||||
if (isActiveDocument) {
|
||||
try {
|
||||
const requestUrl = (req.originalUrl || '/').startsWith('/')
|
||||
? req.originalUrl || '/'
|
||||
|
||||
@@ -20,11 +20,7 @@
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import type { FSEntry } from '../../../stores/fs/FSEntry';
|
||||
|
||||
/**
|
||||
* Minimal Redis surface we need — `get` / `set` with EX TTL. Typed as a subset
|
||||
* of ioredis so callers can pass either the real cluster client or a mock
|
||||
* without needing the full Cluster type here.
|
||||
*/
|
||||
/** The subset of ioredis the loader uses, so tests can pass a mock. */
|
||||
export interface SiteConfigCache {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(
|
||||
@@ -35,42 +31,6 @@ export interface SiteConfigCache {
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site-level configuration loaded from `.puter_site_config` at the site root.
|
||||
* Lets a hosted site customize how the server responds for it — currently
|
||||
* limited to error-page mapping (e.g. SPA fallback that serves `/index.html`
|
||||
* for any 404 with a 200 status).
|
||||
*
|
||||
* The on-disk JSON shape:
|
||||
*
|
||||
* { "errors": { "404": { "file": "/index.html", "status": 200 } } }
|
||||
*
|
||||
* Other static-hosting platforms (Vercel `vercel.json`, Netlify/Amplify
|
||||
* `_redirects`, nginx `error_page`) express the same idea with different
|
||||
* syntax. The internal `SiteConfig` is intentionally narrow so additional
|
||||
* parsers can normalize to it without churning the consumer in `puterSite.ts` —
|
||||
* see `SITE_CONFIG_FILENAMES` for the lookup list.
|
||||
*
|
||||
* Security posture (every input here originates from a user-uploaded file
|
||||
* served on the open internet):
|
||||
*
|
||||
* - The on-disk file is size-capped before parse (`MAX_CONFIG_BYTES`),
|
||||
* - JSON parsing is wrapped in try/catch — a malformed file silently falls back
|
||||
* to default behavior, never 5xx,
|
||||
* - Every `file` value is normalized as if it were a URL path: it must start with
|
||||
* `/`, gets `pathPosix.normalize`d so `..` is collapsed, and is re-anchored
|
||||
* under the site root before any FS lookup,
|
||||
* - Status codes are clamped to a strict allow-list (the request side only
|
||||
* honours 4xx/5xx error keys; the response side validates the `status` is a
|
||||
* legitimate HTTP integer),
|
||||
* - The config file itself is hidden from public serving by the caller
|
||||
* (`isSiteConfigPath`) — same status/body as any other missing path, no
|
||||
* separate 403 that would leak its existence.
|
||||
*
|
||||
* Loop safety is the consumer's responsibility: when serving an error page, do
|
||||
* NOT re-consult `errors` if the error page itself is missing, otherwise a
|
||||
* misconfigured site could spin a 404→404→404 cycle.
|
||||
*/
|
||||
export interface SiteErrorRule {
|
||||
/** Absolute path under the site root (e.g. `/index.html`). */
|
||||
file: string;
|
||||
@@ -78,39 +38,32 @@ export interface SiteErrorRule {
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-site config from `.puter_site_config` at the site root, e.g. `{ "errors":
|
||||
* { "404": { "file": "/index.html", "status": 200 } } }`. Every value comes
|
||||
* from a user-uploaded file; the parser validates it before it reaches this
|
||||
* shape. When serving an error page the consumer must not consult `errors`
|
||||
* again if that page is missing, or a misconfigured site loops.
|
||||
*/
|
||||
export interface SiteConfig {
|
||||
/** Map of HTTP status code → custom error rule. Keys are 4xx/5xx. */
|
||||
/** HTTP status code (4xx/5xx) → error rule. */
|
||||
errors: Record<number, SiteErrorRule>;
|
||||
}
|
||||
|
||||
const MAX_CONFIG_BYTES = 64 * 1024;
|
||||
|
||||
// Cache key prefix is distinct from `subdomains:` (SubdomainStore) and
|
||||
// other Redis users — keep this in sync if you rename, otherwise stale
|
||||
// entries from prior deploys could be read back as configs.
|
||||
const CACHE_KEY_PREFIX = 'puter-site-config:';
|
||||
const CACHE_TTL_SECONDS = 60;
|
||||
// Sentinel for "we looked, the site has no config" so repeated visits
|
||||
// to a config-less site don't keep round-tripping S3 to confirm.
|
||||
// Cached "site has no config" so config-less sites don't re-read storage.
|
||||
const NEGATIVE_CACHE_MARKER = '__none__';
|
||||
|
||||
/**
|
||||
* Filenames consulted at the site root, in priority order. First file that
|
||||
* parses to a non-empty config wins. Extending this list to add Vercel /
|
||||
* Netlify / nginx adapters is the entrypoint for multi-format support — each
|
||||
* parser receives the raw text and returns a normalized `SiteConfig` (or null
|
||||
* if the file isn't valid in that format).
|
||||
*/
|
||||
/** Filenames consulted at the site root, in priority order. */
|
||||
const SITE_CONFIG_FILENAMES: ReadonlyArray<{
|
||||
name: string;
|
||||
parse: (text: string) => SiteConfig | null;
|
||||
}> = [{ name: '.puter_site_config', parse: parsePuterSiteConfig }];
|
||||
|
||||
/**
|
||||
* Returns true if `urlPath` (already normalized to start with `/`) names the
|
||||
* site config file. Used by `puterSite.ts` to suppress direct serving so the
|
||||
* deployment shape isn't leaked to visitors.
|
||||
*/
|
||||
/** Whether `urlPath` names a config file, which is never served publicly. */
|
||||
export function isSiteConfigPath(urlPath: string): boolean {
|
||||
const base = pathPosix.basename(urlPath);
|
||||
return SITE_CONFIG_FILENAMES.some((f) => f.name === base);
|
||||
@@ -119,12 +72,7 @@ export function isSiteConfigPath(urlPath: string): boolean {
|
||||
interface LoadSiteConfigArgs {
|
||||
/** Absolute site root path (e.g. `/<username>/Public`). */
|
||||
rootPath: string;
|
||||
/**
|
||||
* Stable identifier for the site root, used as the cache key. We key on
|
||||
* `rootDirId` (not the subdomain) so that renaming a subdomain — or
|
||||
* pointing multiple subdomains at the same directory — neither orphans nor
|
||||
* duplicates the cached entry.
|
||||
*/
|
||||
/** Cache key; the directory rather than the subdomain, which can be renamed. */
|
||||
rootDirId: number;
|
||||
fsEntryStore: {
|
||||
getEntryByPath: (path: string) => Promise<FSEntry | null>;
|
||||
@@ -138,21 +86,14 @@ interface LoadSiteConfigArgs {
|
||||
contentLength: number | null;
|
||||
}>;
|
||||
};
|
||||
/**
|
||||
* Optional Redis cache. When omitted, every request re-reads the config
|
||||
* from S3 — fine for tests, slow for prod. Cache failures are swallowed
|
||||
* (best-effort): a transient Redis blip just falls through to the live
|
||||
* read, never errors the request.
|
||||
*/
|
||||
/** Best-effort; cache failures fall through to a live read. */
|
||||
cache?: SiteConfigCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate and parse the site config. Returns null when no config file exists,
|
||||
* the file is unreadable, oversized, or fails validation — callers must treat
|
||||
* null as "behave like there is no config" and never raise the error to the
|
||||
* visitor. Errors are logged for the operator but never surfaced to the
|
||||
* request.
|
||||
* Locate and parse the site config. Null when there is none or it is
|
||||
* unreadable, oversized, or invalid; errors are logged, never surfaced to the
|
||||
* visitor.
|
||||
*/
|
||||
export async function loadSiteConfig(
|
||||
args: LoadSiteConfigArgs,
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { withSpan } from '../../../util/span.js';
|
||||
import { HttpError } from '../HttpError.js';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
FREE_SUBSCRIPTION_IDS,
|
||||
} from '../../../services/metering/consts.js';
|
||||
|
||||
/**
|
||||
* Sliding-window rate limiter with swappable, **co-resident** backends.
|
||||
@@ -534,16 +538,15 @@ function resolveKey(req, scope, strategy) {
|
||||
/**
|
||||
* Bucket identity for an authenticated actor: `<user>[:<app>][:<worker>]`.
|
||||
*
|
||||
* The app segment is the app the actor acts as (`effectiveApp`, so an access
|
||||
* token minted by an app lands in that app's bucket). The worker segment is the
|
||||
* worker's session uid, unique per (user, app, worker name). Without these, a
|
||||
* busy app or worker drains the limit shared by everything else the same user
|
||||
* runs.
|
||||
* The app segment is the app the actor acts as, so an access token minted by an
|
||||
* app lands in that app's bucket. The worker segment is the worker's session
|
||||
* uid, unique per (user, app, worker name). Without these, a busy app or worker
|
||||
* drains the limit shared by everything else the same user runs.
|
||||
*/
|
||||
function actorKey(actor, userId) {
|
||||
const parts = [userId];
|
||||
const app = actor.effectiveApp ?? actor.app;
|
||||
if (app?.uid) parts.push(app.uid);
|
||||
const appUid = actor.effectiveApp?.uid;
|
||||
if (appUid) parts.push(appUid);
|
||||
if (actor.session?.kind === 'worker' && actor.session.uid) {
|
||||
parts.push(actor.session.uid);
|
||||
}
|
||||
@@ -709,6 +712,41 @@ export async function checkRateLimit(key, limit, windowMs, backend) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge a route-shaped rate-limit spec from inside a handler.
|
||||
*
|
||||
* `rateLimitGate` takes one static spec per route, which is wrong for a route
|
||||
* whose cost depends on a request parameter: `stat` with `return_shares` does
|
||||
* the same work as the share-listing route, and should spend from the same
|
||||
* budget. This resolves the key and the per-subscription limit exactly as the
|
||||
* gate does — same spec in, same bucket out — so a handler can charge a second
|
||||
* scope conditionally. Returns true if allowed; fails open on backend error.
|
||||
* Takes the array form too; windows charge in order, no refund on refusal.
|
||||
*/
|
||||
export async function consumeRouteRateLimit(req, spec) {
|
||||
if (Array.isArray(spec)) {
|
||||
for (const window of spec) {
|
||||
if (!(await consumeRouteRateLimit(req, window))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const {
|
||||
window: windowMs,
|
||||
key: strategy = 'fingerprint',
|
||||
scope,
|
||||
backend,
|
||||
} = spec;
|
||||
const backendPair = resolveBackend(backend);
|
||||
const key = resolveKey(req, scope ?? req.route?.path ?? 'route', strategy);
|
||||
try {
|
||||
const limit = await resolveSubscriptionLimit(req, spec);
|
||||
return await backendPair.rate(key, limit, windowMs);
|
||||
} catch (err) {
|
||||
console.error('[rate-limit] handler charge failed, failing open:', err);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read whether `key` still has budget, without spending any. The twin to
|
||||
* `checkRateLimit` for gates whose budget is consumed by an outcome rather than
|
||||
@@ -808,6 +846,15 @@ export const CONCURRENT_SLOT_TTL_MS = ORPHAN_SAFETY_TTL_MS;
|
||||
* actor, no metering, metering throws) falls through to the base — rate /
|
||||
* concurrency limiting should never _amplify_ a request failure path.
|
||||
*/
|
||||
// An unlisted free plan would otherwise take `limit`, the paid cap.
|
||||
function overrideFor(bySubscription, subscriptionId) {
|
||||
const own = bySubscription[subscriptionId];
|
||||
if (typeof own === 'number') return own;
|
||||
return FREE_SUBSCRIPTION_IDS.has(subscriptionId)
|
||||
? bySubscription[DEFAULT_FREE_SUBSCRIPTION]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function resolveSubscriptionLimit(req, opts) {
|
||||
const base = opts.limit;
|
||||
if (!opts.bySubscription || !meteringService) return base;
|
||||
@@ -815,7 +862,7 @@ async function resolveSubscriptionLimit(req, opts) {
|
||||
if (!actor?.user?.uuid) return base;
|
||||
try {
|
||||
const sub = await meteringService.getActorSubscription(actor);
|
||||
const override = opts.bySubscription[sub.id];
|
||||
const override = overrideFor(opts.bySubscription, sub.id);
|
||||
return typeof override === 'number' ? override : base;
|
||||
} catch {
|
||||
return base;
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
checkDriverRateLimit,
|
||||
checkRateLimit,
|
||||
concurrencyGate,
|
||||
consumeRouteRateLimit,
|
||||
configureRateLimit,
|
||||
listConfiguredRateLimitBackends,
|
||||
peekRateLimit,
|
||||
@@ -406,6 +407,51 @@ describe('rateLimitGate — bySubscription overrides', () => {
|
||||
expect(await runGate(opts, paidReq)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('holds a free plan the spec never named to the free cap', async () => {
|
||||
// A team seat resolves to `org_seat_free`; no driver enumerates it, and
|
||||
// falling through to `limit` would outrank an ordinary free account.
|
||||
configureRateLimit({
|
||||
metering: {
|
||||
getActorSubscription: async () => ({ id: 'org_seat_free' }),
|
||||
},
|
||||
});
|
||||
const opts = {
|
||||
limit: 100,
|
||||
window: 60_000,
|
||||
bySubscription: { user_free: 1 },
|
||||
key: 'user',
|
||||
scope: 'rl-sub-orgseat',
|
||||
};
|
||||
const req = () => ({
|
||||
actor: { user: { id: 7, uuid: 'seat' } },
|
||||
headers: {},
|
||||
});
|
||||
expect(await runGate(opts, req())).toBeUndefined();
|
||||
expect(isHttpError(await runGate(opts, req()))).toBe(true);
|
||||
});
|
||||
|
||||
it('still gives a paid plan the base when it names no cap of its own', async () => {
|
||||
configureRateLimit({
|
||||
metering: {
|
||||
getActorSubscription: async () => ({ id: 'some-paid-tier' }),
|
||||
},
|
||||
});
|
||||
const opts = {
|
||||
limit: 2,
|
||||
window: 60_000,
|
||||
bySubscription: { user_free: 1 },
|
||||
key: 'user',
|
||||
scope: 'rl-sub-paid-unlisted',
|
||||
};
|
||||
const req = () => ({
|
||||
actor: { user: { id: 8, uuid: 'paid2' } },
|
||||
headers: {},
|
||||
});
|
||||
expect(await runGate(opts, req())).toBeUndefined();
|
||||
expect(await runGate(opts, req())).toBeUndefined();
|
||||
expect(isHttpError(await runGate(opts, req()))).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the base `limit` when metering throws', async () => {
|
||||
configureRateLimit({
|
||||
metering: {
|
||||
@@ -1427,6 +1473,91 @@ describe('checkRateLimit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('consumeRouteRateLimit', () => {
|
||||
beforeEach(() => configureRateLimit());
|
||||
afterEach(() => configureRateLimit());
|
||||
|
||||
const uniqueScope = () => `charge-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
it('spends from the same bucket the route gate does', async () => {
|
||||
// The whole point of the helper: a handler charge and the gate must
|
||||
// resolve the identical key for the same spec + request.
|
||||
const opts = { limit: 2, window: 60_000, key: 'user', scope: uniqueScope() };
|
||||
const req = { actor: { user: { id: 7 } }, headers: {} };
|
||||
|
||||
expect(await consumeRouteRateLimit(req, opts)).toBe(true);
|
||||
|
||||
const next = vi.fn();
|
||||
await rateLimitGate(opts)(req, {}, next);
|
||||
expect(next.mock.calls[0][0]).toBeUndefined();
|
||||
|
||||
// Gate + helper together exhausted the bucket; either side now rejects.
|
||||
expect(await consumeRouteRateLimit(req, opts)).toBe(false);
|
||||
const rejectedNext = vi.fn();
|
||||
await rateLimitGate(opts)(req, {}, rejectedNext);
|
||||
expect(isHttpError(rejectedNext.mock.calls[0][0])).toBe(true);
|
||||
});
|
||||
|
||||
it("buckets per user under the 'user' strategy", async () => {
|
||||
const opts = { limit: 1, window: 60_000, key: 'user', scope: uniqueScope() };
|
||||
const reqFor = (id) => ({ actor: { user: { id } }, headers: {} });
|
||||
expect(await consumeRouteRateLimit(reqFor(1), opts)).toBe(true);
|
||||
expect(await consumeRouteRateLimit(reqFor(1), opts)).toBe(false);
|
||||
expect(await consumeRouteRateLimit(reqFor(2), opts)).toBe(true);
|
||||
});
|
||||
|
||||
it('charges every window of an array spec, and any refusal refuses', async () => {
|
||||
// The array form routes use (burst + sustained); the tightest bites.
|
||||
const specs = [
|
||||
{ limit: 5, window: 60_000, key: 'user', scope: uniqueScope() },
|
||||
{ limit: 2, window: 60_000, key: 'user', scope: uniqueScope() },
|
||||
];
|
||||
const req = { actor: { user: { id: 9 } }, headers: {} };
|
||||
expect(await consumeRouteRateLimit(req, specs)).toBe(true);
|
||||
expect(await consumeRouteRateLimit(req, specs)).toBe(true);
|
||||
expect(await consumeRouteRateLimit(req, specs)).toBe(false);
|
||||
// The refusing window really was the second one, not the first.
|
||||
expect(await consumeRouteRateLimit(req, specs[0])).toBe(true);
|
||||
});
|
||||
|
||||
it('applies bySubscription overrides via the wired metering service', async () => {
|
||||
configureRateLimit({
|
||||
metering: {
|
||||
getActorSubscription: async (actor) => ({
|
||||
id: actor.user.uuid === 'free' ? 'user_free' : 'other',
|
||||
}),
|
||||
},
|
||||
});
|
||||
const opts = {
|
||||
limit: 5,
|
||||
window: 60_000,
|
||||
bySubscription: { user_free: 1 },
|
||||
key: 'user',
|
||||
scope: uniqueScope(),
|
||||
};
|
||||
const freeReq = { actor: { user: { id: 1, uuid: 'free' } }, headers: {} };
|
||||
expect(await consumeRouteRateLimit(freeReq, opts)).toBe(true);
|
||||
expect(await consumeRouteRateLimit(freeReq, opts)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails open when the backend throws', async () => {
|
||||
configureRateLimit({
|
||||
default: 'redis',
|
||||
redis: {
|
||||
multi: () => {
|
||||
throw new Error('redis exploded');
|
||||
},
|
||||
},
|
||||
});
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const opts = { limit: 1, window: 60_000, key: 'ip', scope: uniqueScope() };
|
||||
const req = { ip: '5.5.5.5', headers: {} };
|
||||
expect(await consumeRouteRateLimit(req, opts)).toBe(true);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('peekRateLimit', () => {
|
||||
beforeEach(() => configureRateLimit());
|
||||
afterEach(() => configureRateLimit());
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { makeActor } from '../../actor.js';
|
||||
import { TokenService } from '../../../services/auth/TokenService.js';
|
||||
import {
|
||||
createStepUpGate,
|
||||
@@ -187,7 +188,10 @@ describe('createStepUpGate', () => {
|
||||
});
|
||||
const next = vi.fn();
|
||||
const req = reqWith(undefined, USER_UUID, {
|
||||
actor: { user: { uuid: USER_UUID }, app: { uid: 'app-xyz' } },
|
||||
actor: makeActor({
|
||||
user: { uuid: USER_UUID },
|
||||
app: { uid: 'app-xyz' },
|
||||
}),
|
||||
} as never);
|
||||
gate(req, {} as Response, next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
@@ -201,7 +205,10 @@ describe('createStepUpGate', () => {
|
||||
});
|
||||
const next = vi.fn();
|
||||
const req = reqWith(undefined, USER_UUID, {
|
||||
actor: { user: { uuid: USER_UUID }, app: { uid: 'app-xyz' } },
|
||||
actor: makeActor({
|
||||
user: { uuid: USER_UUID },
|
||||
app: { uid: 'app-xyz' },
|
||||
}),
|
||||
} as never);
|
||||
gate(req, {} as Response, next);
|
||||
expect(next.mock.calls[0][0]?.statusCode).toBe(403);
|
||||
@@ -212,7 +219,10 @@ describe('createStepUpGate', () => {
|
||||
const gate = createStepUpGate({ tokenService: ts });
|
||||
const next = vi.fn();
|
||||
const req = reqWith(undefined, USER_UUID, {
|
||||
actor: { user: { uuid: USER_UUID }, app: { uid: 'app-xyz' } },
|
||||
actor: makeActor({
|
||||
user: { uuid: USER_UUID },
|
||||
app: { uid: 'app-xyz' },
|
||||
}),
|
||||
} as never);
|
||||
gate(req, {} as Response, next);
|
||||
expect(next.mock.calls[0][0]?.statusCode).toBe(403);
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { Request, RequestHandler } from 'express';
|
||||
import type { IConfig } from '../../../types';
|
||||
import type { UserRow } from '../../../stores/user/UserStore';
|
||||
import type { TokenService } from '../../../services/auth/TokenService';
|
||||
import { isAppActor } from '../../actor';
|
||||
import { sessionCookieFlags } from '../../../util/cookieFlags';
|
||||
import { HttpError } from '../HttpError';
|
||||
|
||||
@@ -169,7 +170,12 @@ export function createStepUpGate(deps: {
|
||||
// elevate, so step-up is unsatisfiable for it. Any other actor — most
|
||||
// importantly a root/human session with no app id in its token — falls
|
||||
// through and must present the elevation proof.
|
||||
const appUid = req.actor?.app?.uid;
|
||||
// The app it carries directly, not the one acting: an access token can
|
||||
// never satisfy step-up either, so widening the exemption to the token
|
||||
// chain would only hand it a free pass.
|
||||
const appUid = isAppActor(req.actor)
|
||||
? req.actor?.effectiveApp?.uid
|
||||
: undefined;
|
||||
if (appUid && deps.allowedAppUids?.includes(appUid)) {
|
||||
next();
|
||||
return;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import type { Request, RequestHandler, Response, NextFunction } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { isPlainUserActor } from '../../actor';
|
||||
import { HttpError } from '../HttpError';
|
||||
import type { IConfig } from '../../../types';
|
||||
import type { UserStore, UserRow } from '../../../stores/user/UserStore';
|
||||
@@ -99,12 +100,7 @@ export const createSessionCookieGate = (config: IConfig): RequestHandler => {
|
||||
export const createWebSessionActorGate = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
const actor = req.actor;
|
||||
if (
|
||||
!actor?.user ||
|
||||
!actor.session ||
|
||||
(actor as { app?: unknown }).app ||
|
||||
(actor as { accessToken?: unknown }).accessToken
|
||||
) {
|
||||
if (!actor?.user || !actor.session || !isPlainUserActor(actor)) {
|
||||
return next(
|
||||
new HttpError(401, 'Web session required', {
|
||||
legacyCode: 'session_required',
|
||||
|
||||
+111
-236
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import type { UserRow } from '../../stores/user/UserStore';
|
||||
import type { Actor } from '../actor';
|
||||
|
||||
/**
|
||||
@@ -33,13 +34,7 @@ export type TokenSource =
|
||||
| 'query'
|
||||
| 'handshake';
|
||||
|
||||
/**
|
||||
* Every route method PuterRouter exposes. Mirrors the express router surface
|
||||
* plus WebDAV verbs that some endpoints still use.
|
||||
*
|
||||
* `use` and `all` don't map to a single HTTP verb — they're treated uniformly
|
||||
* by the materializer (see `v2/server.ts`).
|
||||
*/
|
||||
/** Express router methods plus the WebDAV verbs some endpoints use. */
|
||||
export type RouteMethod =
|
||||
| 'use'
|
||||
| 'all'
|
||||
@@ -59,37 +54,36 @@ export type RouteMethod =
|
||||
| 'move';
|
||||
|
||||
/**
|
||||
* Path shape accepted by express route methods. Kept permissive rather than
|
||||
* re-exporting express's internal `PathParams` (which isn't stable public
|
||||
* API).
|
||||
* Express route path; kept permissive since express's `PathParams` is not
|
||||
* public API.
|
||||
*/
|
||||
export type RoutePath = string | RegExp | Array<string | RegExp>;
|
||||
|
||||
/**
|
||||
* Per-route options declared by the caller.
|
||||
*
|
||||
* The materializer (`v2/server.ts#materializeRoute`) translates these into a
|
||||
* middleware chain in this order:
|
||||
*
|
||||
* subdomain → requireAuth (+ suspended) → emailConfirmed →
|
||||
* requireUserActor → adminOnly → allowedAppIds → phoneVerified →
|
||||
* cardVerified → requireReputation → requireSubscription → rateLimit →
|
||||
* requireCredits → concurrent → caller `middleware: []` → handler
|
||||
*
|
||||
* `requireUserActor`, `adminOnly`, and `allowedAppIds` all imply `requireAuth`;
|
||||
* the materializer dedupes so only one auth gate ends up in the chain.
|
||||
* Commented-out slots are reserved for the next chunks (body parsing, post-auth
|
||||
* gates, timing).
|
||||
*/
|
||||
/** The account-verification factors a route can ask for by name. */
|
||||
export type VerificationFactor = 'phone' | 'card';
|
||||
|
||||
/** The user fields the account gates read; all optional, like `Actor.user`. */
|
||||
export type AccountGateUser = Partial<
|
||||
Pick<
|
||||
UserRow,
|
||||
| 'suspended'
|
||||
| 'email_confirmed'
|
||||
| 'requires_email_confirmation'
|
||||
| 'requires_phone_verification'
|
||||
| 'requires_card_verification'
|
||||
| 'requires_password_change'
|
||||
| 'phone'
|
||||
| 'card_fingerprint'
|
||||
>
|
||||
>;
|
||||
|
||||
/** One rate-limit window. See `RouteOptions.rateLimit` for semantics. */
|
||||
export interface RouteRateLimit {
|
||||
limit: number;
|
||||
window: number;
|
||||
/**
|
||||
* Per-subscription overrides for `limit` (`SubscriptionPolicy.id` → cap).
|
||||
* Same mechanic as `concurrent.bySubscription`: resolved via the metering
|
||||
* service per request; falls back to the base `limit` when there's no actor
|
||||
* / no metering / no match.
|
||||
* Per-`SubscriptionPolicy.id` overrides for `limit`; the base applies when
|
||||
* there is no match.
|
||||
*/
|
||||
bySubscription?: Record<string, number>;
|
||||
key?: 'fingerprint' | 'ip' | 'user' | ((req: Request) => string);
|
||||
@@ -97,241 +91,148 @@ export interface RouteRateLimit {
|
||||
backend?: 'memory' | 'redis' | 'kv';
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-route options. The materializer (`v2/server.ts`) turns them into a
|
||||
* middleware chain in this order:
|
||||
*
|
||||
* subdomain → requireAuth → emailConfirmed → requireUserActor → adminOnly →
|
||||
* allowedAppIds → phoneVerified → cardVerified → anyVerified →
|
||||
* requireReputation → requireSubscription → rateLimit → requireCredits →
|
||||
* concurrent → `middleware` → handler
|
||||
*
|
||||
* Options that imply `requireAuth` are deduped to a single auth gate.
|
||||
*/
|
||||
export interface RouteOptions {
|
||||
/**
|
||||
* Extra per-route middleware. Applied after built-in gates, before the
|
||||
* handler.
|
||||
*/
|
||||
/** Extra middleware, run after the built-in gates. */
|
||||
middleware?: RequestHandler[];
|
||||
|
||||
/**
|
||||
* Subdomain routing. If set, the route only matches requests whose leftmost
|
||||
* subdomain is in this list (via `next('route')` skip).
|
||||
*
|
||||
* If omitted, verb-routes (get/post/etc.) are restricted to the root origin
|
||||
* only (no subdomain). Pass `'*'` to explicitly match ANY subdomain/root.
|
||||
* `use()` middleware is not gated by default.
|
||||
* Only match requests whose leftmost subdomain is listed. Verb routes
|
||||
* default to the root origin only; `'*'` matches any. `use()` middleware is
|
||||
* not gated by default.
|
||||
*/
|
||||
subdomain?: string | string[];
|
||||
|
||||
/**
|
||||
* Reject anonymous + suspended-user requests with 401/403. Only allows user
|
||||
* and app actors
|
||||
*/
|
||||
/** 401 for anonymous requests, 403 for suspended accounts. */
|
||||
requireAuth?: boolean;
|
||||
|
||||
/** Reject app/access-token actors. Implies `requireAuth`. */
|
||||
requireUserActor?: boolean;
|
||||
|
||||
/**
|
||||
* Only meaningful alongside `requireUserActor`. Relaxes the access-token
|
||||
* half of that gate so a FULL-ACCESS personal access token (the user's own
|
||||
* credential) is admitted — third-party apps and scoped tokens stay
|
||||
* blocked. Use ONLY on user-resource / inference endpoints that gate with
|
||||
* `requireUserActor` purely to keep apps out (e.g. the AI proxy). NEVER set
|
||||
* on account/security/credential routes — those must stay closed to every
|
||||
* access token. Default-deny: omitting this keeps PATs blocked.
|
||||
* With `requireUserActor`, admit full-access personal access tokens; apps
|
||||
* and scoped tokens stay blocked. Never set on account or security routes.
|
||||
*/
|
||||
allowFullAccessToken?: boolean;
|
||||
|
||||
/** Allows access-tokens */
|
||||
/** Admit scoped access tokens. */
|
||||
allowAccessToken?: boolean;
|
||||
|
||||
/**
|
||||
* Reject bare user-session actors ("root" tokens — no app, no access token)
|
||||
* with 403 `app_or_api_token_required`. Implies `requireAuth`. Use on API
|
||||
* surfaces that must only be driven by a delegated credential (app/worker
|
||||
* token or a dashboard-minted API token), so a copied session token can't
|
||||
* double as an API credential. Compose with `requireUserActor` +
|
||||
* `allowFullAccessToken` to further narrow to API tokens only (apps stay
|
||||
* out too). Worker sessions (`session.kind === 'worker'`) always pass — a
|
||||
* deployed worker is a delegated, revocable credential, never a root
|
||||
* token.
|
||||
* Reject bare account-session actors with 403 `app_or_api_token_required`;
|
||||
* worker sessions pass. Implies `requireAuth`.
|
||||
*/
|
||||
noUserSession?: boolean;
|
||||
|
||||
/**
|
||||
* Reject unless the actor's username is `admin`, `system`, or one of the
|
||||
* extras in this array. `true` means just `admin`/`system`; an array adds
|
||||
* to that pair (does not replace it). Implies `requireAuth`.
|
||||
*
|
||||
* Also requires a _root token_ (an actor with no app anywhere in its token
|
||||
* chain), so an admin acting through a third-party app can't reach the
|
||||
* route. Pair with `allowedAppIds` to make an admin route reachable by
|
||||
* specific apps: the combination admits a root token OR a token scoped to
|
||||
* an allowed app.
|
||||
*
|
||||
* Does NOT imply `requireUserActor` — a root token still includes an
|
||||
* admin's full-access personal access token, not only browser sessions.
|
||||
* Combine with `requireUserActor` to restrict to browser sessions.
|
||||
* Reject unless the username is `admin`, `system`, or one of the listed
|
||||
* extras, and the actor is a root token (no app in its chain). Pair with
|
||||
* `allowedAppIds` to also admit tokens scoped to those apps. Implies
|
||||
* `requireAuth`, not `requireUserActor`.
|
||||
*/
|
||||
adminOnly?: boolean | string[];
|
||||
|
||||
/**
|
||||
* Reject an app-under-user actor whose app is not in this list. Implies
|
||||
* Reject actors acting as an app that is not listed, whether carried
|
||||
* directly or through the app that issued their access token. Actors with
|
||||
* no app in the chain pass; see `allowedAppIdsGate`. Implies
|
||||
* `requireAuth`.
|
||||
*
|
||||
* Not an app gate: actors carrying no app of their own — browser sessions,
|
||||
* full-access personal access tokens, workers, and access tokens issued by
|
||||
* an app — pass through untouched. `adminOnly` + `allowedAppIds` relies on
|
||||
* that. See `allowedAppIdsGate` before using this to scope a route to
|
||||
* apps.
|
||||
*/
|
||||
allowedAppIds?: string[];
|
||||
|
||||
/**
|
||||
* Allow users whose account is pending email confirmation to access this
|
||||
* route. By default, any authenticated route rejects users where
|
||||
* `requires_email_confirmation && !email_confirmed` with 403. Set this to
|
||||
* `true` on essential flows that must remain accessible before
|
||||
* confirmation: logout, email-confirm, whoami, save-account, anti-CSRF
|
||||
* token, etc.
|
||||
*
|
||||
* Only meaningful when the route also requires authentication (via
|
||||
* `requireAuth`, `requireUserActor`, `adminOnly`, or `allowedAppIds`).
|
||||
* Let accounts still pending a verification through. For the flows that
|
||||
* clear the pending state (logout, confirm-email, whoami, save-account).
|
||||
*/
|
||||
allowUnconfirmed?: boolean;
|
||||
|
||||
/**
|
||||
* Reject unless the actor's user has a confirmed email. 400 with
|
||||
* `account_is_not_verified` on failure. No-op when
|
||||
* `config.strict_email_verification_required` is falsy, so self-hosted
|
||||
* deployments can opt in via config. Implies `requireAuth` but NOT
|
||||
* `requireUserActor` — app-under-user actors also carry a `.user`, so
|
||||
* verification applies uniformly whether the user acts directly or through
|
||||
* an app.
|
||||
* Reject unless the email is confirmed. Inert unless
|
||||
* `config.strict_email_verification_required`. Implies `requireAuth`.
|
||||
*/
|
||||
requireVerified?: boolean;
|
||||
|
||||
/**
|
||||
* Reject unless the user has a phone number verified on file (verified at
|
||||
* some point, not merely never asked to). 403
|
||||
* `phone_verification_required`. Implies `requireAuth`; independent of the
|
||||
* default-on pending-verification gate, which only turns away accounts the
|
||||
* abuse harness flagged.
|
||||
*
|
||||
* Opt-in per route, for surfaces worth the friction of an SMS round trip.
|
||||
* 403 `phone_verification_required` unless a phone was verified. Implies
|
||||
* `requireAuth`.
|
||||
*/
|
||||
requirePhoneVerified?: boolean;
|
||||
|
||||
/**
|
||||
* Reject unless the user has a card verified on file. 403
|
||||
* `card_verification_required`. Same shape as `requirePhoneVerified` — the
|
||||
* opt-in, "must have actually done it" counterpart of the pending gate.
|
||||
* 403 `card_verification_required` unless a card was verified. Implies
|
||||
* `requireAuth`.
|
||||
*/
|
||||
requireCardVerified?: boolean;
|
||||
|
||||
/**
|
||||
* Per-route JSON body parsing override. By default the global parser
|
||||
* handles every `application/json` request with a 50mb limit and stashes
|
||||
* the raw bytes on `req.rawBody` for signature-verification use cases.
|
||||
*
|
||||
* Use this option only when a route needs different parser settings:
|
||||
*
|
||||
* - `false` — opt out of parsing entirely (rare; the route reads the raw
|
||||
* stream itself, e.g. some webhook proxies). The global parser will still
|
||||
* have already run if the content-type was JSON, so this is mostly useful
|
||||
* for routes that accept _non_-JSON body shapes and want to ensure no
|
||||
* further parsers attach.
|
||||
* - `{ limit, type }` — override the limit (e.g., for ML endpoints that
|
||||
* legitimately need 100mb) or the matched content-type list (e.g., to
|
||||
* ALSO accept `application/x-ndjson`).
|
||||
* Reject unless one of the listed factors is verified; a paid plan counts
|
||||
* for `card`. Only factors this deployment can verify are asked for, so
|
||||
* with none verifiable the gate is inert. The 403 lists the verifiable
|
||||
* factors in `factors`. An empty list is a boot error.
|
||||
*/
|
||||
requireAnyVerified?: readonly VerificationFactor[];
|
||||
|
||||
/**
|
||||
* Override the global JSON parser: `false` opts out, `{ limit, type }`
|
||||
* changes the size limit or the matched content types.
|
||||
*/
|
||||
bodyJson?: false | { limit?: string; type?: string | string[] };
|
||||
|
||||
/**
|
||||
* Per-route raw (Buffer) body parser. Use for binary uploads where the
|
||||
* route handler wants `req.body: Buffer` directly. Default content-type
|
||||
* match is `application/octet-stream`; pass `type` to override.
|
||||
* Buffer body parser. Matches `application/octet-stream` unless `type`
|
||||
* overrides.
|
||||
*/
|
||||
bodyRaw?: boolean | { limit?: string; type?: string | string[] };
|
||||
|
||||
/**
|
||||
* Per-route text body parser. `req.body` becomes a string. Default
|
||||
* content-type match is `text/plain`.
|
||||
*/
|
||||
/** Text body parser. Matches `text/plain` by default. */
|
||||
bodyText?: boolean | { limit?: string; type?: string | string[] };
|
||||
|
||||
/**
|
||||
* Per-route urlencoded form parser. `req.body` becomes a parsed object.
|
||||
* Default `extended: true` (uses `qs`); pass `extended: false` for the
|
||||
* built-in `querystring` parser.
|
||||
*/
|
||||
/** Form parser. `extended: true` (default) uses `qs`. */
|
||||
bodyUrlencoded?: boolean | { limit?: string; extended?: boolean };
|
||||
|
||||
/**
|
||||
* Require captcha verification. When `true`, the route rejects requests
|
||||
* that don't carry valid `captchaToken` + `captchaAnswer` fields in the
|
||||
* body. No-op when captcha is disabled in config.
|
||||
* Require valid `captchaToken` + `captchaAnswer` in the body. No-op when
|
||||
* captcha is disabled.
|
||||
*/
|
||||
captcha?: boolean;
|
||||
|
||||
/**
|
||||
* Require a valid one-time anti-CSRF token in `req.body.anti_csrf`. The
|
||||
* token is consumed on use. Requires authentication (keyed by user uuid).
|
||||
* Require a valid one-time `anti_csrf` body token. Needs an authenticated
|
||||
* user.
|
||||
*/
|
||||
antiCsrf?: boolean;
|
||||
|
||||
/**
|
||||
* Restrict the route to pages on this deployment's own GUI origin
|
||||
* (`config.origin`, plus any `config.allow_gui_origins`). Requests with no
|
||||
* `Origin` header still pass — the gate stops cross-origin browser pages,
|
||||
* not non-browser clients.
|
||||
*
|
||||
* For routes that return a session credential to the caller. See
|
||||
* `guiOriginGate` for why reflected-CORS makes this necessary.
|
||||
* Only allow pages on this deployment's GUI origin; requests with no
|
||||
* `Origin` header still pass. For routes returning a session credential;
|
||||
* see `guiOriginGate`.
|
||||
*/
|
||||
guiOriginOnly?: boolean;
|
||||
|
||||
/**
|
||||
* Per-route rate limiting. In-memory sliding window keyed by request
|
||||
* identity.
|
||||
*
|
||||
* `key` controls how requests are bucketed:
|
||||
*
|
||||
* - `'fingerprint'` (default) — network hash (IP + headers), refined by the
|
||||
* client's device fingerprint when one was supplied. Safe for shared IPs
|
||||
* (offices, VPNs): each device gets its own bucket instead of the whole
|
||||
* network sharing one.
|
||||
* - `'ip'` — bare IP address.
|
||||
* - `'user'` — actor's user ID. Use for authenticated routes where you want
|
||||
* per-account limits.
|
||||
* - `(req) => string` — custom key function.
|
||||
*
|
||||
* `scope` is an optional namespace prefix to isolate counters between
|
||||
* routes that share the same key strategy. Defaults to the route path.
|
||||
*
|
||||
* `backend` selects the storage backend ('memory' / 'redis' / 'kv'). All
|
||||
* registered backends stay co-resident at runtime, so different routes can
|
||||
* pick whichever fits their access pattern. Omitting `backend` uses the
|
||||
* server-wide default (`config.rate_limit.backend`).
|
||||
*
|
||||
* An array applies every limit independently (a request must pass all of
|
||||
* them). Use this to pair a per-client budget with a coarser backstop on a
|
||||
* different key — e.g. per-fingerprint for fairness on shared IPs, plus
|
||||
* per-IP so rotating headers can't mint fresh fingerprint buckets
|
||||
* indefinitely. Give each entry its own `scope` so the counters don't
|
||||
* collide.
|
||||
* Sliding-window rate limit. `key` picks the bucket: `fingerprint`
|
||||
* (default; network hash refined by device fingerprint), `ip`, `user`, or a
|
||||
* function. `scope` namespaces the counter (defaults to the route path);
|
||||
* `backend` defaults to `config.rate_limit.backend`. An array applies every
|
||||
* limit independently; give each its own `scope`.
|
||||
*/
|
||||
rateLimit?: RouteRateLimit | RouteRateLimit[];
|
||||
|
||||
/**
|
||||
* Concurrent in-flight limiting. Caps how many requests for this key are
|
||||
* simultaneously in flight, rather than how many fire per window. A slot is
|
||||
* acquired before the handler runs and released on `res.finish` /
|
||||
* `res.close` — aborted requests still give their slot back.
|
||||
*
|
||||
* { concurrent: { limit: 5, key: 'user' } } { concurrent: { limit: 5,
|
||||
* bySubscription: { user_free: 2 } } }
|
||||
*
|
||||
* `bySubscription` overrides the base `limit` per subscription tier
|
||||
* (`SubscriptionPolicy.id`) — `user_free`, `temp_free`, `unlimited` out of
|
||||
* the box. Requires the metering service to be wired into the rate-limit
|
||||
* module (`configureRateLimit({ metering: ... })`) and an authenticated
|
||||
* actor; otherwise the base `limit` applies.
|
||||
*
|
||||
* `key`, `scope`, `backend` parallel the `rateLimit` option exactly;
|
||||
* there's no `window` — that's the whole point of the second gate.
|
||||
* Cap simultaneous in-flight requests per key; slots are released on
|
||||
* response finish or close. `bySubscription` overrides `limit` per
|
||||
* `SubscriptionPolicy.id` when metering is wired in. `key`, `scope`,
|
||||
* `backend` as in `rateLimit`.
|
||||
*/
|
||||
concurrent?: {
|
||||
limit: number;
|
||||
@@ -342,55 +243,23 @@ export interface RouteOptions {
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject an account with nothing left of its usage budget with 402
|
||||
* `insufficient_funds`.
|
||||
*
|
||||
* For routes that spend metered resources on the caller's behalf — moving
|
||||
* file content, making object-store requests. Not for the routes that show
|
||||
* an account what it has or let it delete things: an account that has run
|
||||
* out still needs to be able to see its files, clear space, and reach its
|
||||
* billing pages, and blocking that leaves no way out other than paying.
|
||||
*
|
||||
* Anonymous callers and worker sessions pass; see `requireCreditsGate`.
|
||||
* Rate limits remain the bound on request _count_ — this bounds spend, and
|
||||
* lags the traffic that produced it by the metering buffer window, so it
|
||||
* stops sustained usage rather than a burst.
|
||||
* 402 `insufficient_funds` when the account has no usage budget left. For
|
||||
* routes that spend metered resources, not for the ones that let an account
|
||||
* see or free up what it has. Anonymous callers and worker sessions pass.
|
||||
*/
|
||||
requireCredits?: boolean;
|
||||
|
||||
/**
|
||||
* Reject a caller whose plan doesn't include this route with 402
|
||||
* `subscription_required`.
|
||||
*
|
||||
* `true` accepts any plan that isn't one of the free ones, so a plan
|
||||
* registered by an extension counts without being named here. An array of
|
||||
* `SubscriptionPolicy.id`s accepts only those, for a feature that belongs
|
||||
* to specific plans (`requireSubscription: ['business', 'pro']`).
|
||||
*
|
||||
* Opt-in: nothing is subscriber-only unless it says so. Implies
|
||||
* `requireAuth` — there is no plan to read off an anonymous caller. The
|
||||
* answer comes from the metering service's per-actor subscription cache, so
|
||||
* the check costs a map lookup on a warm actor. Distinct from
|
||||
* `requireCredits`, which asks whether an account has budget left rather
|
||||
* than which plan it is on; a route may want both.
|
||||
* 402 `subscription_required` unless the plan qualifies: `true` accepts any
|
||||
* non-free plan, an array of `SubscriptionPolicy.id`s only those. Implies
|
||||
* `requireAuth`.
|
||||
*/
|
||||
requireSubscription?: boolean | string[];
|
||||
|
||||
/**
|
||||
* Reject a caller whose account isn't trusted enough for this route with
|
||||
* 403 `reputation_required`.
|
||||
*
|
||||
* The value names a tier; the minimum score that tier takes is deployment
|
||||
* config (`reputationGate.tiers`), so the number a surface is worth stays
|
||||
* out of the surface's declaration and can be retuned without editing it. A
|
||||
* tier the running config doesn't define is inert and everyone passes — a
|
||||
* deployment that doesn't score its accounts turns nobody away.
|
||||
*
|
||||
* Opt-in, and implies `requireAuth`: there is no account to have earned
|
||||
* anything on an anonymous caller. The declaration decides the chain, so a
|
||||
* route asking for a tier is authenticated whether or not the tier
|
||||
* currently enforces anything. `false` is "no requirement", the same as
|
||||
* leaving it out.
|
||||
* 403 `reputation_required` unless the account meets the named tier.
|
||||
* Thresholds come from `reputationGate.tiers`; an undefined tier is inert.
|
||||
* Implies `requireAuth`.
|
||||
*/
|
||||
requireReputation?: string | false;
|
||||
|
||||
@@ -464,15 +333,21 @@ export type AuthRequired<O extends RouteOptions> = O extends {
|
||||
: O extends { requireCardVerified: true }
|
||||
? true
|
||||
: O extends {
|
||||
requireSubscription:
|
||||
| true
|
||||
| readonly string[]
|
||||
| string[];
|
||||
requireAnyVerified:
|
||||
| readonly VerificationFactor[]
|
||||
| VerificationFactor[];
|
||||
}
|
||||
? true
|
||||
: O extends { requireReputation: string }
|
||||
: O extends {
|
||||
requireSubscription:
|
||||
| true
|
||||
| readonly string[]
|
||||
| string[];
|
||||
}
|
||||
? true
|
||||
: false;
|
||||
: O extends { requireReputation: string }
|
||||
? true
|
||||
: false;
|
||||
|
||||
/** Express `Request` with `actor` narrowed based on the route's options. */
|
||||
export type TypedRequest<O extends RouteOptions> = Omit<Request, 'actor'> & {
|
||||
|
||||
@@ -27,6 +27,7 @@ export {
|
||||
isSystemActor,
|
||||
isAppActor,
|
||||
isAccessTokenActor,
|
||||
isPlainUserActor,
|
||||
actorUid,
|
||||
assertResolvedActor,
|
||||
makeActor,
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { ORG_SEAT_FREE } from './orgSeatFreePolicy.js';
|
||||
import { REGISTERED_USER_FREE } from './registeredUserFreePolicy.js';
|
||||
import { TEMP_USER_FREE } from './tempUserFreePolicy.js';
|
||||
|
||||
export const SUB_POLICIES = [TEMP_USER_FREE, REGISTERED_USER_FREE];
|
||||
export const SUB_POLICIES = [
|
||||
TEMP_USER_FREE,
|
||||
REGISTERED_USER_FREE,
|
||||
ORG_SEAT_FREE,
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 { ORG_SEAT_FREE_SUBSCRIPTION } from '../../services/metering/consts.js';
|
||||
import { REGISTERED_USER_FREE } from './registeredUserFreePolicy.js';
|
||||
|
||||
/** Half the free plan, so a team cannot mint full free tiers by provisioning. */
|
||||
export const ORG_SEAT_FREE = {
|
||||
id: ORG_SEAT_FREE_SUBSCRIPTION,
|
||||
monthUsageAllowance: Math.floor(
|
||||
REGISTERED_USER_FREE.monthUsageAllowance / 2,
|
||||
),
|
||||
monthlyStorageAllowance: Math.floor(
|
||||
REGISTERED_USER_FREE.monthlyStorageAllowance / 2,
|
||||
),
|
||||
};
|
||||
@@ -738,6 +738,41 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
});
|
||||
}
|
||||
|
||||
it('rejects subscriber-only models for a team seat on the free org plan', async () => {
|
||||
// `org_seat_free` pays nothing, so it must not reach a paid model —
|
||||
// the gate checks every free plan, not two named ones.
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
id: 'subonly-seat',
|
||||
aliases: [],
|
||||
costs_currency: 'usd-cents',
|
||||
costs: { 'input-tokens': 100, 'output-tokens': 100 },
|
||||
max_tokens: 8192,
|
||||
subscriberOnly: true,
|
||||
},
|
||||
]);
|
||||
const d = await makeDriver();
|
||||
vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue(
|
||||
1_000_000,
|
||||
);
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getActorSubscription',
|
||||
).mockResolvedValue({ id: 'org_seat_free' } as never);
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
d.complete({
|
||||
model: 'subonly-seat',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'permission_denied',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects subscriber-only models for the default free subscription', async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
|
||||
@@ -23,12 +23,10 @@ import { EventMap } from '../../clients/event/types.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
} from '../../services/metering/consts.js';
|
||||
import { FREE_SUBSCRIPTION_IDS } from '../../services/metering/consts.js';
|
||||
import type { CreditHold } from '../../services/metering/types.js';
|
||||
import { NO_CREDIT_HOLD } from '../../services/metering/types.js';
|
||||
import type { MeteringService } from '../../services/metering/MeteringService.js';
|
||||
import type { DriverStreamResult } from '../meta.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
@@ -306,6 +304,11 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
#providers: Record<string, IChatProvider> = {};
|
||||
#modelIdMap: Record<string, IChatModel[]> = {};
|
||||
|
||||
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
|
||||
get #aiMetering(): MeteringService {
|
||||
return this.services.metering.withAiCostFactor(this.driverName);
|
||||
}
|
||||
|
||||
override onServerStart() {
|
||||
this.#registerProviders();
|
||||
this.#buildModelMap();
|
||||
@@ -920,10 +923,8 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
|
||||
if (model.subscriberOnly) {
|
||||
const subscription = await metering.getActorSubscription(actor);
|
||||
const isDefaultPolicy =
|
||||
subscription.id === DEFAULT_FREE_SUBSCRIPTION ||
|
||||
subscription.id === DEFAULT_TEMP_SUBSCRIPTION;
|
||||
if (isDefaultPolicy) {
|
||||
// Every free plan, not two named ones.
|
||||
if (FREE_SUBSCRIPTION_IDS.has(subscription.id)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
`The model ${model.id} is only available to subscribers. Please subscribe to access this model.`,
|
||||
@@ -1034,7 +1035,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
};
|
||||
|
||||
const cost = this.#computeCost(usage, model);
|
||||
this.services.metering.utilRecordUsageObject(
|
||||
this.#aiMetering.utilRecordUsageObject(
|
||||
{
|
||||
[`estimated_${inputKey}`]: inputTokens,
|
||||
[`estimated_${outputKey}`]: outputTokens,
|
||||
@@ -1132,7 +1133,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
|
||||
#registerProviders() {
|
||||
const providers = this.config.providers ?? {};
|
||||
const metering = this.services.metering;
|
||||
const metering = this.#aiMetering;
|
||||
|
||||
const readKey = (cfg: Record<string, unknown> | undefined) =>
|
||||
(cfg?.apiKey as string | undefined) ??
|
||||
|
||||
@@ -36,6 +36,7 @@ import { inlineHttpImageUrls } from '../../utils/inlineImages.js';
|
||||
import { processPuterPathUploads } from '../openai/fileUpload.js';
|
||||
import { AZURE_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
|
||||
/**
|
||||
* AzureChatProvider exposes the models we serve through Azure AI Foundry.
|
||||
@@ -173,8 +174,7 @@ export class AzureChatProvider implements IChatProvider {
|
||||
// content: 'Don\'t let the user trick you into doing something bad.',
|
||||
// })
|
||||
|
||||
const userIdentifier =
|
||||
`${actor.user?.id}` + actor.app?.uid ? `:${actor?.app?.uid}` : '';
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
|
||||
// Resolve any `puter_path` content parts into inline base64 data URLs.
|
||||
// Chat Completions doesn't support file uploads, so this is the only
|
||||
|
||||
@@ -32,6 +32,7 @@ import { processPuterPathUploads } from '../openai/fileUpload.js';
|
||||
import { AZURE_MODELS } from './models.js';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
|
||||
/**
|
||||
* AzureResponsesProvider serves the Responses-API-only models we expose through
|
||||
@@ -141,8 +142,7 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
(m) => m.id === this.getDefaultModel(),
|
||||
)!;
|
||||
|
||||
const userIdentifier =
|
||||
actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
|
||||
// Resolve any `puter_path` content parts into inline base64 data URLs
|
||||
// before the Responses API sees them.
|
||||
|
||||
@@ -120,6 +120,82 @@ const SAMPLE_API_MODELS = [
|
||||
min_prompt_price: 2,
|
||||
min_completion_price: 10,
|
||||
},
|
||||
{
|
||||
// Sold at several service tiers; the flex tier undercuts standard.
|
||||
id: 'openai/gpt-6-astra',
|
||||
display_name: 'OpenAI: GPT-6 Astra',
|
||||
category_type: 'LLM',
|
||||
supported_endpoint_types: ['openai'],
|
||||
context_length: 1050000,
|
||||
max_output_tokens: 128000,
|
||||
min_prompt_price: 5,
|
||||
min_completion_price: 25,
|
||||
providers: [
|
||||
{
|
||||
provider_slug: 'openai/flex',
|
||||
service_tier: 'flex',
|
||||
prompt_price: 5,
|
||||
completion_price: 25,
|
||||
context_length: 400000,
|
||||
},
|
||||
{
|
||||
provider_slug: 'azure',
|
||||
service_tier: 'standard',
|
||||
prompt_price: 7.5,
|
||||
completion_price: 37.5,
|
||||
},
|
||||
{
|
||||
provider_slug: 'openai',
|
||||
service_tier: 'standard',
|
||||
prompt_price: 10,
|
||||
completion_price: 50,
|
||||
},
|
||||
{
|
||||
provider_slug: 'openai/priority',
|
||||
service_tier: 'priority',
|
||||
prompt_price: 20,
|
||||
completion_price: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Flex is the only tier on offer — nothing to pin.
|
||||
id: 'example/flex-only-model',
|
||||
display_name: 'Flex Only',
|
||||
category_type: 'LLM',
|
||||
supported_endpoint_types: ['openai'],
|
||||
context_length: 128000,
|
||||
max_output_tokens: 4096,
|
||||
min_prompt_price: 1,
|
||||
min_completion_price: 2,
|
||||
providers: [
|
||||
{
|
||||
provider_slug: 'example/flex',
|
||||
service_tier: 'flex',
|
||||
prompt_price: 1,
|
||||
completion_price: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Catalog ids can contain a colon of their own.
|
||||
id: 'example/colon-model:free',
|
||||
display_name: 'Colon Model (free)',
|
||||
category_type: 'LLM',
|
||||
supported_endpoint_types: ['openai'],
|
||||
context_length: 64000,
|
||||
max_output_tokens: 4096,
|
||||
min_prompt_price: 0,
|
||||
min_completion_price: 0,
|
||||
providers: [
|
||||
{
|
||||
provider_slug: 'example',
|
||||
service_tier: 'standard',
|
||||
prompt_price: 0,
|
||||
completion_price: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Non-chat modality — filtered out.
|
||||
id: 'black-forest-labs/flux-2.1',
|
||||
@@ -262,10 +338,87 @@ describe('InfronProvider model catalog', () => {
|
||||
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('quotes the tier Infron routes to by default, not the cheaper flex tier', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const models = await provider.models();
|
||||
// Cheapest standard offering is Azure at $7.5/$37.5 per million,
|
||||
// not the $5/$25 flex tier the catalog floor reports.
|
||||
expect(models).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'infron:openai/gpt-6-astra',
|
||||
costs: expect.objectContaining({
|
||||
prompt: 750,
|
||||
completion: 3750,
|
||||
input_cache_read: 750,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('lists a :flex id alongside the default-tier id, each at its own price', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const models = await provider.models();
|
||||
|
||||
expect(models).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'infron:openai/gpt-6-astra',
|
||||
costs: expect.objectContaining({
|
||||
prompt: 750,
|
||||
completion: 3750,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'infron:openai/gpt-6-astra:flex',
|
||||
costs: expect.objectContaining({
|
||||
prompt: 500,
|
||||
completion: 2500,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'infron:openai/gpt-6-astra:priority',
|
||||
costs: expect.objectContaining({
|
||||
prompt: 2000,
|
||||
completion: 10000,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('takes a tier variant context window from its own offering', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const models = await provider.models();
|
||||
const flex = models.find(
|
||||
(m) => m.id === 'infron:openai/gpt-6-astra:flex',
|
||||
);
|
||||
// The flex offering caps context below the model-level figure.
|
||||
expect(flex?.context).toBe(400000);
|
||||
});
|
||||
|
||||
it('accepts the explicit :standard spelling as an alias of the plain id', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const models = await provider.models();
|
||||
const base = models.find((m) => m.id === 'infron:openai/gpt-6-astra');
|
||||
expect(base?.aliases).toContain('openai/gpt-6-astra:standard');
|
||||
});
|
||||
|
||||
it('still offers an explicit tier id when no default tier is sold', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const ids = await provider.list();
|
||||
// The plain id leaves routing to Infron; the :flex id pins the one
|
||||
// tier on offer. Same price, different routing guarantee.
|
||||
expect(ids).toContain('infron:example/flex-only-model');
|
||||
expect(ids).toContain('infron:example/flex-only-model:flex');
|
||||
});
|
||||
|
||||
it('converts USD-per-million-token prices to microcents per token', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const models = await provider.models();
|
||||
// $10/M tokens → 10 * 100 = 1000 microcents per token.
|
||||
// $10/M tokens → 10 * 100 = 1000 microcents per token. This model
|
||||
// carries no per-tier breakdown, so the catalog floor is used.
|
||||
expect(models).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -314,6 +467,68 @@ describe('InfronProvider.complete request shape', () => {
|
||||
expect(args.usage).toEqual({ include: true });
|
||||
});
|
||||
|
||||
it('pins the standard service tier so billing matches the quoted price', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:openai/gpt-6-astra',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].provider).toEqual({
|
||||
service_tier: 'standard',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves routing to Infron for models with no standard tier', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:example/flex-only-model',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect('provider' in createMock.mock.calls[0]![0]).toBe(false);
|
||||
});
|
||||
|
||||
it('pins the tier named by a :flex model id and strips it from the wire id', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:openai/gpt-6-astra:flex',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.model).toBe('openai/gpt-6-astra');
|
||||
expect(args.provider).toEqual({ service_tier: 'flex' });
|
||||
});
|
||||
|
||||
it('keeps a colon-bearing catalog id intact rather than reading it as a tier', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:example/colon-model:free',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.model).toBe('example/colon-model:free');
|
||||
expect(args.provider).toEqual({ service_tier: 'standard' });
|
||||
});
|
||||
|
||||
it('only sets stream_options.include_usage when streaming', async () => {
|
||||
const { provider } = makeProvider();
|
||||
|
||||
|
||||
@@ -31,12 +31,25 @@ import type {
|
||||
ICompleteArguments,
|
||||
} from '../../types.js';
|
||||
|
||||
/**
|
||||
* One upstream offering of a model in Infron's catalog. The same model is often
|
||||
* served at several service tiers with different prices; `flex` is
|
||||
* batch-oriented and cheapest, `priority` is the fastest and dearest.
|
||||
*/
|
||||
type InfronApiProviderOffer = {
|
||||
provider_slug?: string;
|
||||
service_tier?: string;
|
||||
prompt_price?: number;
|
||||
completion_price?: number;
|
||||
context_length?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shape of one entry in Infron's `GET /v1/models` catalog. Unlike OpenRouter
|
||||
* there is no `pricing` object; prices are USD per million tokens in
|
||||
* `min_prompt_price` / `min_completion_price`, and the catalog mixes non-chat
|
||||
* modalities (image, video, embeddings) that this provider filters out via
|
||||
* `category_type`.
|
||||
* there is no `pricing` object; prices are USD per million tokens, per offering
|
||||
* in `providers` and as a catalog-wide floor in `min_prompt_price` /
|
||||
* `min_completion_price`. The catalog mixes non-chat modalities (image, video,
|
||||
* embeddings) that this provider filters out via `category_type`.
|
||||
*/
|
||||
type InfronApiModel = {
|
||||
id: string;
|
||||
@@ -49,6 +62,117 @@ type InfronApiModel = {
|
||||
min_prompt_price?: number;
|
||||
min_completion_price?: number;
|
||||
min_request_price?: number;
|
||||
providers?: InfronApiProviderOffer[];
|
||||
};
|
||||
|
||||
/** Tier Infron routes to when a request carries no explicit `service_tier`. */
|
||||
const DEFAULT_SERVICE_TIER = 'standard';
|
||||
|
||||
/** What one tier of a model costs, in USD per million tokens. */
|
||||
type InfronTierPrices = {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
context?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cheapest offering of each service tier a model sells. Tiers differ in price
|
||||
* and sometimes in context window, so each one is quoted from its own row
|
||||
* rather than from the catalog-wide `min_*` floor.
|
||||
*/
|
||||
const pricesByTier = (model: InfronApiModel) => {
|
||||
const byTier = new Map<string, InfronTierPrices>();
|
||||
for (const offer of model.providers ?? []) {
|
||||
const tier = offer.service_tier;
|
||||
if (!tier) continue;
|
||||
const prompt = offer.prompt_price ?? 0;
|
||||
const completion = offer.completion_price ?? 0;
|
||||
const seen = byTier.get(tier);
|
||||
if (seen && seen.prompt + seen.completion <= prompt + completion) {
|
||||
continue;
|
||||
}
|
||||
byTier.set(tier, { prompt, completion, context: offer.context_length });
|
||||
}
|
||||
return byTier;
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits a model id into the id Infron expects on the wire and the tier to pin.
|
||||
* Catalog ids can contain a colon themselves (`…:free`), so an exact catalog
|
||||
* match always wins over reading the last segment as a tier suffix.
|
||||
*/
|
||||
const resolveTier = (id: string, catalog: InfronApiModel[]) => {
|
||||
const tiersOf = (model: InfronApiModel) =>
|
||||
new Set((model.providers ?? []).map((offer) => offer.service_tier));
|
||||
|
||||
const exact = catalog.find((model) => model.id === id);
|
||||
if (exact) {
|
||||
return {
|
||||
wireModelId: id,
|
||||
// Unsuffixed ids are quoted at the default tier, so pin it — left
|
||||
// unset, Infron load-balances across tiers and could bill another.
|
||||
tier: tiersOf(exact).has(DEFAULT_SERVICE_TIER)
|
||||
? DEFAULT_SERVICE_TIER
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const cut = id.lastIndexOf(':');
|
||||
const base =
|
||||
cut > 0 ? catalog.find((m) => m.id === id.slice(0, cut)) : undefined;
|
||||
const tier = id.slice(cut + 1);
|
||||
if (base && tiersOf(base).has(tier)) return { wireModelId: base.id, tier };
|
||||
return { wireModelId: id, tier: undefined };
|
||||
};
|
||||
|
||||
/**
|
||||
* One listed model: the default tier under the plain id, or a single service
|
||||
* tier under a `…:<tier>` id priced from that tier's own offering.
|
||||
*/
|
||||
const coerceModel = (
|
||||
model: InfronApiModel,
|
||||
prices: InfronTierPrices,
|
||||
tier?: string,
|
||||
): IChatModel => {
|
||||
const suffix = tier ? `:${tier}` : '';
|
||||
const shortId = model.id.split('/').slice(1).join('/');
|
||||
// Catalog prices are USD per million tokens; costs are microcents per
|
||||
// token, so the conversion is ×100.
|
||||
const promptCost = Math.round(prices.prompt * 100);
|
||||
return {
|
||||
id: `infron:${model.id}${suffix}`,
|
||||
name: `${model.display_name || model.id} (Infron${tier ? `, ${tier}` : ''})`,
|
||||
aliases: [
|
||||
`${model.id}${suffix}`,
|
||||
...(model.display_name && !tier ? [model.display_name] : []),
|
||||
`infron/${model.id}${suffix}`,
|
||||
`${shortId}${suffix}`,
|
||||
// The plain id already means the default tier; accept the
|
||||
// explicit spelling of it too.
|
||||
...(tier ? [] : [`${model.id}:${DEFAULT_SERVICE_TIER}`]),
|
||||
],
|
||||
context: tier
|
||||
? (prices.context ?? model.context_length)
|
||||
: model.context_length,
|
||||
max_tokens: model.max_output_tokens ?? 0,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt',
|
||||
output_cost_key: 'completion',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt: promptCost,
|
||||
completion: Math.round(prices.completion * 100),
|
||||
// The catalog carries no cache-read price; charge the full
|
||||
// prompt rate in the fallback path so cached tokens are never
|
||||
// billed below list. The normal path bills the
|
||||
// gateway-reported cost instead.
|
||||
input_cache_read: promptCost,
|
||||
// USD per request → microcents per request.
|
||||
request: Math.round(
|
||||
(model.min_request_price ?? 0) * 1_000_000 * 100,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type InfronUsage = OpenAI.Completions.CompletionUsage & {
|
||||
@@ -112,17 +236,25 @@ export class InfronProvider implements IChatProvider {
|
||||
[m.id, ...(m.aliases || [])].includes(model),
|
||||
) || availableModels.find((m) => m.id === this.getDefaultModel())!;
|
||||
|
||||
const modelIdForParams = modelUsed.id.startsWith('infron:')
|
||||
const catalogId = modelUsed.id.startsWith('infron:')
|
||||
? modelUsed.id.slice('infron:'.length)
|
||||
: modelUsed.id;
|
||||
|
||||
// A `…:<tier>` id carries the tier to pin; a plain id means the
|
||||
// default tier. Only tiers the model actually sells are pinned —
|
||||
// Infron routes freely (and reports the tier back) when unset.
|
||||
const { wireModelId, tier } = resolveTier(
|
||||
catalogId,
|
||||
await this.#rawModels(),
|
||||
);
|
||||
|
||||
const actor = Context.get('actor');
|
||||
|
||||
messages = await OpenAIUtil.process_input_messages(messages);
|
||||
|
||||
const completionParams = {
|
||||
messages,
|
||||
model: modelIdForParams,
|
||||
model: wireModelId,
|
||||
...(tools ? { tools } : {}),
|
||||
max_tokens,
|
||||
temperature,
|
||||
@@ -132,6 +264,10 @@ export class InfronProvider implements IChatProvider {
|
||||
stream_options: { include_usage: true },
|
||||
}
|
||||
: {}),
|
||||
// Without this Infron load-balances across service tiers, so a
|
||||
// request could be billed at a tier other than the one whose
|
||||
// price we quote in the catalog.
|
||||
...(tier ? { provider: { service_tier: tier } } : {}),
|
||||
// Surfaces the authoritative `cost` field (USD) on the
|
||||
// response so metering doesn't depend on catalog prices.
|
||||
usage: { include: true },
|
||||
@@ -216,7 +352,8 @@ export class InfronProvider implements IChatProvider {
|
||||
});
|
||||
}
|
||||
|
||||
async models() {
|
||||
/** The catalog as Infron returns it, kv-cached and shared by callers. */
|
||||
async #rawModels(): Promise<InfronApiModel[]> {
|
||||
let models = kv.get(KV_MODELS_KEY) as InfronApiModel[] | undefined;
|
||||
if (!models) {
|
||||
try {
|
||||
@@ -235,7 +372,11 @@ export class InfronProvider implements IChatProvider {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
if (!models) return [];
|
||||
return models ?? [];
|
||||
}
|
||||
|
||||
async models() {
|
||||
const models = await this.#rawModels();
|
||||
const coerced_models: IChatModel[] = [];
|
||||
for (const model of models) {
|
||||
// The catalog mixes chat with image/video/embedding/search
|
||||
@@ -245,40 +386,19 @@ export class InfronProvider implements IChatProvider {
|
||||
if (!(model.supported_endpoint_types ?? []).includes('openai')) {
|
||||
continue;
|
||||
}
|
||||
// Catalog prices are USD per million tokens; costs are
|
||||
// microcents per token, so the conversion is ×100.
|
||||
const promptCost = Math.round((model.min_prompt_price ?? 0) * 100);
|
||||
coerced_models.push({
|
||||
id: `infron:${model.id}`,
|
||||
name: `${model.display_name || model.id} (Infron)`,
|
||||
aliases: [
|
||||
model.id,
|
||||
...(model.display_name ? [model.display_name] : []),
|
||||
`infron/${model.id}`,
|
||||
model.id.split('/').slice(1).join('/'),
|
||||
],
|
||||
context: model.context_length,
|
||||
max_tokens: model.max_output_tokens ?? 0,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt',
|
||||
output_cost_key: 'completion',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt: promptCost,
|
||||
completion: Math.round(
|
||||
(model.min_completion_price ?? 0) * 100,
|
||||
),
|
||||
// The catalog carries no cache-read price; charge the
|
||||
// full prompt rate in the fallback path so cached
|
||||
// tokens are never billed below list. The normal path
|
||||
// bills the gateway-reported cost instead.
|
||||
input_cache_read: promptCost,
|
||||
// USD per request → microcents per request.
|
||||
request: Math.round(
|
||||
(model.min_request_price ?? 0) * 1_000_000 * 100,
|
||||
),
|
||||
},
|
||||
});
|
||||
const byTier = pricesByTier(model);
|
||||
const defaultPrices = byTier.get(DEFAULT_SERVICE_TIER) ?? {
|
||||
prompt: model.min_prompt_price ?? 0,
|
||||
completion: model.min_completion_price ?? 0,
|
||||
};
|
||||
// The unsuffixed id is the default tier; every other tier the
|
||||
// model sells gets its own `…:<tier>` id so callers can ask for
|
||||
// one by name and see what it costs.
|
||||
coerced_models.push(coerceModel(model, defaultPrices));
|
||||
for (const [tier, prices] of byTier) {
|
||||
if (tier === DEFAULT_SERVICE_TIER) continue;
|
||||
coerced_models.push(coerceModel(model, prices, tier));
|
||||
}
|
||||
}
|
||||
return coerced_models;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
import type { Actor } from '../../../../core/actor.js';
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
@@ -361,10 +361,10 @@ describe('MetaProvider.complete request shape', () => {
|
||||
|
||||
it('derives safety_identifier from the actor and truncates it to 64 chars', async () => {
|
||||
createMock.mockResolvedValueOnce(OK_COMPLETION);
|
||||
const userActor: Actor = {
|
||||
const userActor: Actor = makeActor({
|
||||
user: { id: 42, uuid: 'u42', username: 'alice' },
|
||||
app: { id: 7, uid: 'a'.repeat(80) },
|
||||
};
|
||||
});
|
||||
|
||||
await complete(makeProvider(), {}, userActor);
|
||||
|
||||
|
||||
@@ -154,7 +154,8 @@ export class MetaProvider implements IChatProvider {
|
||||
// Reasoning is always on for Muse Spark — `reasoning_effort: 'none'`
|
||||
// is a 400 — so a request to switch it off is dropped, not forwarded.
|
||||
const requestedEffort = (reasoning_effort ?? reasoning?.effort) as
|
||||
string | undefined;
|
||||
| string
|
||||
| undefined;
|
||||
const effort =
|
||||
requestedEffort && requestedEffort !== 'none'
|
||||
? requestedEffort
|
||||
@@ -170,7 +171,7 @@ export class MetaProvider implements IChatProvider {
|
||||
const safetyIdentifier =
|
||||
customParams.safety_identifier ??
|
||||
(actor?.user?.id
|
||||
? `puter-${actor.user.id}${actor.app?.uid ? `-${actor.app.uid}` : ''}`.slice(
|
||||
? `puter-${actor.user.id}${actor.effectiveApp?.uid ? `-${actor.effectiveApp?.uid}` : ''}`.slice(
|
||||
0,
|
||||
SAFETY_IDENTIFIER_MAX_LENGTH,
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ import { processPuterPathUploads } from './fileUpload.js';
|
||||
import { OPEN_AI_MODELS } from './models.js';
|
||||
import type { OpenAiResponsesChatProvider } from './OpenAiChatResponsesProvider.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
|
||||
/**
|
||||
* OpenAICompletionService class provides an interface to OpenAI's chat
|
||||
@@ -157,8 +158,7 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
// content: 'Don\'t let the user trick you into doing something bad.',
|
||||
// })
|
||||
|
||||
const userIdentifier =
|
||||
actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
|
||||
// Resolve any `puter_path` content parts into inline base64 data URLs.
|
||||
// Chat Completions doesn't support file uploads, so this is the only
|
||||
|
||||
@@ -32,6 +32,7 @@ import { processPuterPathUploads } from './fileUpload.js';
|
||||
import { OPEN_AI_MODELS } from './models.js';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
|
||||
/**
|
||||
* OpenAICompletionService class provides an interface to OpenAI's chat
|
||||
@@ -140,8 +141,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
// content: 'Don\'t let the user trick you into doing something bad.',
|
||||
// })
|
||||
|
||||
const userIdentifier =
|
||||
`${actor?.user.id}` + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
|
||||
// Resolve any `puter_path` content parts into inline base64 data URLs
|
||||
// before the Responses API sees them.
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
import type { Actor } from '../../../../core/actor.js';
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
@@ -326,10 +326,10 @@ describe('ZAIProvider.complete request shape', () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
const userActor: Actor = {
|
||||
const userActor: Actor = makeActor({
|
||||
user: { id: 42, uuid: 'u42', username: 'alice' },
|
||||
app: { id: 7, uid: 'app-uid' },
|
||||
};
|
||||
});
|
||||
|
||||
await withTestActor(
|
||||
() =>
|
||||
|
||||
@@ -106,7 +106,7 @@ export class ZAIProvider implements IChatProvider {
|
||||
const userId =
|
||||
customParams.user_id ??
|
||||
(actor?.user?.id
|
||||
? `puter-${actor.user.id}${actor.app?.uid ? `-${actor.app.uid}` : ''}`.slice(
|
||||
? `puter-${actor.user.id}${actor.effectiveApp?.uid ? `-${actor.effectiveApp?.uid}` : ''}`.slice(
|
||||
0,
|
||||
128,
|
||||
)
|
||||
@@ -164,7 +164,7 @@ export class ZAIProvider implements IChatProvider {
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor,
|
||||
actor!,
|
||||
`zai:${modelUsed.id}`,
|
||||
costsOverrideFromModel,
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Readable } from 'node:stream';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import type { MeteringService } from '../../services/metering/MeteringService.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { secureFetch } from '../../util/secureHttp.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
@@ -73,6 +74,11 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
#providers: Record<string, IImageProvider> = {};
|
||||
#modelIdMap: Record<string, IImageModel[]> = {};
|
||||
|
||||
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
|
||||
get #aiMetering(): MeteringService {
|
||||
return this.services.metering.withAiCostFactor(this.driverName);
|
||||
}
|
||||
|
||||
override onServerStart() {
|
||||
this.#registerProviders();
|
||||
this.#buildModelMap();
|
||||
@@ -253,7 +259,7 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
|
||||
#registerProviders() {
|
||||
const providers = this.config.providers ?? {};
|
||||
const m = this.services.metering;
|
||||
const m = this.#aiMetering;
|
||||
|
||||
const readKey = (
|
||||
...cfgs: Array<Record<string, unknown> | undefined>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { OPEN_AI_IMAGE_GENERATION_MODELS } from './models.js';
|
||||
import { fetchImageAsBase64, isHttpUrl } from '../../inputImage.js';
|
||||
import { estimateTextTokens } from '../../../util/tokenEstimate.js';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
|
||||
interface OpenAIImageUsage {
|
||||
inputTokens: number;
|
||||
@@ -176,8 +177,7 @@ export class OpenAiImageProvider implements IImageProvider {
|
||||
}
|
||||
|
||||
const actor = Context.get('actor');
|
||||
const userIdentifier =
|
||||
actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
|
||||
const estimatedPromptTokenCount =
|
||||
this.#estimatePromptTokenCount(prompt);
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
import { XAI_IMAGE_GENERATION_MODELS } from './models.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { assertInputImageString } from '../../inputImage.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
|
||||
const DEFAULT_MODEL = 'grok-imagine-image';
|
||||
// xAI's Grok Imagine edit endpoint accepts up to 3 source images per request.
|
||||
@@ -94,8 +95,7 @@ export class XAIImageProvider implements IImageProvider {
|
||||
const aspectRatio = this.#aspectRatio(ratio);
|
||||
|
||||
const actor = Context.get('actor');
|
||||
const userIdentifier =
|
||||
actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
|
||||
const outputPriceInCents = selectedModel.costs[`output:${resolution}`];
|
||||
const mediaInputPriceInCents = selectedModel.costs.media_input ?? 0;
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { mimeFromName } from '../../util/fileSigning.js';
|
||||
import type { MeteringService } from '../../services/metering/MeteringService.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
import { loadFileInput, type LoadedFile } from '../util/fileInput.js';
|
||||
@@ -102,6 +103,11 @@ export class OCRDriver extends PuterDriver {
|
||||
readonly rateLimit = AI_RATE_LIMIT;
|
||||
readonly concurrent = AI_CONCURRENT;
|
||||
|
||||
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
|
||||
get #aiMetering(): MeteringService {
|
||||
return this.services.metering.withAiCostFactor(this.driverName);
|
||||
}
|
||||
|
||||
override getReportedCosts() {
|
||||
return Object.entries(OCR_COSTS).map(([usageType, ucentsPerUnit]) => ({
|
||||
usageType,
|
||||
@@ -132,9 +138,11 @@ export class OCRDriver extends PuterDriver {
|
||||
const providers = this.config.providers ?? {};
|
||||
|
||||
const textract = providers['aws-textract'] as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const textractAws = (textract?.aws ?? textract) as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const textractAccessKey = textractAws?.access_key as string | undefined;
|
||||
const textractSecretKey = textractAws?.secret_key as string | undefined;
|
||||
const textractRegion =
|
||||
@@ -342,7 +350,7 @@ export class OCRDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
const pages = pageCount || 1;
|
||||
this.services.metering.incrementUsage(
|
||||
this.#aiMetering.incrementUsage(
|
||||
actor,
|
||||
usageType,
|
||||
pages,
|
||||
@@ -454,14 +462,14 @@ export class OCRDriver extends PuterDriver {
|
||||
const pagesProcessed =
|
||||
response?.usageInfo?.pagesProcessed ??
|
||||
(Array.isArray(response?.pages) ? response.pages.length : 1);
|
||||
this.services.metering.incrementUsage(
|
||||
this.#aiMetering.incrementUsage(
|
||||
actor,
|
||||
'mistral-ocr:ocr:page',
|
||||
pagesProcessed,
|
||||
OCR_COSTS['mistral-ocr:ocr:page'] * pagesProcessed,
|
||||
);
|
||||
if (annotations) {
|
||||
this.services.metering.incrementUsage(
|
||||
this.#aiMetering.incrementUsage(
|
||||
actor,
|
||||
'mistral-ocr:annotations:page',
|
||||
pagesProcessed,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../services/metering/MeteringService.js';
|
||||
import type { DriverStreamResult } from '../meta.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
@@ -69,6 +70,11 @@ export class VoiceChangerDriver extends PuterDriver {
|
||||
readonly rateLimit = AI_RATE_LIMIT;
|
||||
readonly concurrent = AI_CONCURRENT;
|
||||
|
||||
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
|
||||
get #aiMetering(): MeteringService {
|
||||
return this.services.metering.withAiCostFactor(this.driverName);
|
||||
}
|
||||
|
||||
override getReportedCosts(): Record<string, unknown>[] {
|
||||
return Object.entries(VOICE_CHANGER_COSTS).map(
|
||||
([usageType, ucentsPerUnit]) => ({
|
||||
@@ -87,7 +93,8 @@ export class VoiceChangerDriver extends PuterDriver {
|
||||
|
||||
override onServerStart() {
|
||||
const elevenlabs = this.config.providers?.elevenlabs as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
this.#apiKey =
|
||||
(elevenlabs?.apiKey as string | undefined) ??
|
||||
@@ -296,7 +303,7 @@ export class VoiceChangerDriver extends PuterDriver {
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const stream = Readable.from(Buffer.from(arrayBuffer));
|
||||
this.services.metering.incrementUsage(
|
||||
this.#aiMetering.incrementUsage(
|
||||
actor,
|
||||
usageKey,
|
||||
estimatedSeconds,
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../services/metering/MeteringService.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
import {
|
||||
@@ -66,6 +67,11 @@ export class SpeechToTextDriver extends PuterDriver {
|
||||
|
||||
#providers: Record<string, ISpeechToTextProvider> = {};
|
||||
|
||||
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
|
||||
get #aiMetering(): MeteringService {
|
||||
return this.services.metering.withAiCostFactor(this.driverName);
|
||||
}
|
||||
|
||||
override onServerStart() {
|
||||
this.#registerProviders();
|
||||
}
|
||||
@@ -195,7 +201,7 @@ export class SpeechToTextDriver extends PuterDriver {
|
||||
const deps: ISpeechToTextDeps = {
|
||||
stores: this.stores,
|
||||
fs: this.services.fs,
|
||||
metering: this.services.metering,
|
||||
metering: this.#aiMetering,
|
||||
};
|
||||
|
||||
this.#providers['openai'] = new OpenAISpeechToTextProvider(deps, {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../services/metering/MeteringService.js';
|
||||
import type { DriverStreamResult } from '../meta.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
@@ -73,6 +74,11 @@ export class TTSDriver extends PuterDriver {
|
||||
|
||||
#providers: Record<string, ITTSProvider> = {};
|
||||
|
||||
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
|
||||
get #aiMetering(): MeteringService {
|
||||
return this.services.metering.withAiCostFactor(this.driverName);
|
||||
}
|
||||
|
||||
override onServerStart() {
|
||||
this.#registerProviders();
|
||||
}
|
||||
@@ -244,7 +250,7 @@ export class TTSDriver extends PuterDriver {
|
||||
|
||||
#registerProviders() {
|
||||
const providers = this.config.providers ?? {};
|
||||
const m = this.services.metering;
|
||||
const m = this.#aiMetering;
|
||||
|
||||
const openaiConfig =
|
||||
(providers['openai-tts'] as Record<string, unknown> | undefined) ??
|
||||
@@ -266,7 +272,8 @@ export class TTSDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
const elevenlabs = providers['elevenlabs'] as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const elevenKey =
|
||||
(elevenlabs?.apiKey as string | undefined) ??
|
||||
(elevenlabs?.api_key as string | undefined) ??
|
||||
@@ -277,7 +284,8 @@ export class TTSDriver extends PuterDriver {
|
||||
apiKey: elevenKey,
|
||||
apiBaseUrl: elevenlabs?.apiBaseUrl as string | undefined,
|
||||
defaultVoiceId: elevenlabs?.defaultVoiceId as
|
||||
string | undefined,
|
||||
| string
|
||||
| undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
@@ -288,9 +296,11 @@ export class TTSDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
const polly = providers['aws-polly'] as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const pollyAws = (polly?.aws ?? polly) as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const pollyAccessKey = pollyAws?.access_key as string | undefined;
|
||||
const pollySecretKey = pollyAws?.secret_key as string | undefined;
|
||||
const pollyRegion =
|
||||
@@ -317,9 +327,10 @@ export class TTSDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
#registerGeminiProvider(providers: Record<string, unknown>) {
|
||||
const m = this.services.metering;
|
||||
const m = this.#aiMetering;
|
||||
const gemini = (providers['gemini'] ?? providers['gemini-tts']) as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const geminiKey =
|
||||
(gemini?.apiKey as string | undefined) ??
|
||||
(gemini?.api_key as string | undefined) ??
|
||||
@@ -339,9 +350,10 @@ export class TTSDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
#registerXAIProvider(providers: Record<string, unknown>) {
|
||||
const m = this.services.metering;
|
||||
const m = this.#aiMetering;
|
||||
const xai = (providers['xai'] ?? providers['xai-tts']) as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const xaiKey =
|
||||
(xai?.apiKey as string | undefined) ??
|
||||
(xai?.api_key as string | undefined) ??
|
||||
@@ -361,7 +373,7 @@ export class TTSDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
#registerSpeechifyProvider(providers: Record<string, unknown>) {
|
||||
const m = this.services.metering;
|
||||
const m = this.#aiMetering;
|
||||
const speechify = (providers['speechify'] ??
|
||||
providers['speechify-tts']) as Record<string, unknown> | undefined;
|
||||
const speechifyKey =
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Readable } from 'node:stream';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import type { MeteringService } from '../../services/metering/MeteringService.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { secureFetch } from '../../util/secureHttp.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
@@ -94,6 +95,11 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
#providers: Record<string, IVideoProvider> = {};
|
||||
#modelIdMap: Record<string, IVideoModel[]> = {};
|
||||
|
||||
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
|
||||
get #aiMetering(): MeteringService {
|
||||
return this.services.metering.withAiCostFactor(this.driverName);
|
||||
}
|
||||
|
||||
override onServerStart() {
|
||||
this.#registerProviders();
|
||||
this.#buildModelMap();
|
||||
@@ -303,7 +309,7 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
|
||||
#registerProviders() {
|
||||
const providers = this.config.providers ?? {};
|
||||
const m = this.services.metering;
|
||||
const m = this.#aiMetering;
|
||||
|
||||
// Same lenient reader as ImageGenerationDriver — accept
|
||||
// `apiKey || secret_key`, and fall back from the video-specific
|
||||
@@ -345,9 +351,11 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
// pair its missing apiBaseUrl with the shared block's key (or vice
|
||||
// versa) and point a region-scoped key at the wrong endpoint.
|
||||
const byteplusVideoCfg = providers['byteplus-video-generation'] as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const byteplusSharedCfg = providers['byteplus'] as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const byteplusKey = readKey(byteplusVideoCfg, byteplusSharedCfg);
|
||||
if (byteplusKey) {
|
||||
this.#providers['byteplus-video-generation'] =
|
||||
@@ -356,7 +364,8 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
apiKey: byteplusKey,
|
||||
apiBaseUrl: (byteplusVideoCfg?.apiBaseUrl ??
|
||||
byteplusSharedCfg?.apiBaseUrl) as
|
||||
string | undefined,
|
||||
| string
|
||||
| undefined,
|
||||
},
|
||||
m,
|
||||
);
|
||||
|
||||
@@ -265,7 +265,7 @@ export class AppDriver extends PuterDriver {
|
||||
try {
|
||||
app = await this.appStore.create(fields, {
|
||||
ownerUserId: actor.user.id,
|
||||
appOwner: actor.app?.id ?? null,
|
||||
appOwner: actor.effectiveApp?.id ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isUniqueViolation(err)) throw err;
|
||||
@@ -369,7 +369,7 @@ export class AppDriver extends PuterDriver {
|
||||
for (const app of apps) {
|
||||
if (
|
||||
!app.protected ||
|
||||
actor.app?.uid === app.uid ||
|
||||
actor.effectiveApp?.uid === app.uid ||
|
||||
actor.user?.id === app.owner_user_id
|
||||
) {
|
||||
localVisible.add(app);
|
||||
@@ -836,7 +836,7 @@ export class AppDriver extends PuterDriver {
|
||||
async #canReadApp(app, actor) {
|
||||
if (!app.protected) return true;
|
||||
// Self-app access
|
||||
if (actor.app?.uid === app.uid) return true;
|
||||
if (actor.effectiveApp?.uid === app.uid) return true;
|
||||
// Owner access
|
||||
if (actor.user?.id === app.owner_user_id) return true;
|
||||
// Permission check
|
||||
@@ -857,10 +857,11 @@ export class AppDriver extends PuterDriver {
|
||||
|
||||
async #checkWriteAccess(app, actor) {
|
||||
// App actor matching app_owner
|
||||
const ownApp = actor.effectiveApp;
|
||||
let hasAccess = false;
|
||||
if (!actor.app?.id) {
|
||||
if (!ownApp?.id) {
|
||||
hasAccess = actor.user?.id === app.owner_user_id;
|
||||
} else if (actor.app.id === app.app_owner) {
|
||||
} else if (ownApp.id === app.app_owner) {
|
||||
hasAccess = actor.user?.id === app.owner_user_id;
|
||||
}
|
||||
// System-wide write
|
||||
|
||||
@@ -36,96 +36,26 @@ import {
|
||||
type DriverRequireSubscriptionConfig,
|
||||
} from './meta';
|
||||
|
||||
/** Options for the `@Driver` class decorator. */
|
||||
/**
|
||||
* Options for the `@Driver` class decorator. Each policy takes `{ default?,
|
||||
* methods? }`; a method covered by neither is ungated (rate limits fall through
|
||||
* to the global driver default). See `DriverMeta` for semantics.
|
||||
*/
|
||||
export interface DriverOptions {
|
||||
/**
|
||||
* Unique name for this implementation within its interface. Defaults to the
|
||||
* class name.
|
||||
*/
|
||||
/** Unique within the interface. Defaults to the class name. */
|
||||
name?: string;
|
||||
/** When true, this driver is the default for its interface. */
|
||||
/** The default driver for its interface. */
|
||||
default?: boolean;
|
||||
/**
|
||||
* Rate-limit policy. Each driver method can declare its own limit / window
|
||||
* / storage backend; methods not listed inherit `default`, and undeclared
|
||||
* methods fall through to the global driver default (600/min in
|
||||
* `checkDriverRateLimit`).
|
||||
*
|
||||
* ```ts
|
||||
* @Driver('puter-kvstore', {
|
||||
* rateLimit: {
|
||||
* default: { limit: 600, window: 60_000 },
|
||||
* methods: {
|
||||
* list: { limit: 60, window: 60_000, backend: 'kv' },
|
||||
* set: { limit: 200, window: 60_000, backend: 'redis' },
|
||||
* },
|
||||
* },
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
rateLimit?: DriverRateLimitConfig;
|
||||
/**
|
||||
* Concurrent in-flight policy. Same envelope as `rateLimit` minus `window`.
|
||||
* Adds `bySubscription` to scale the cap by subscription tier
|
||||
* (`SubscriptionPolicy.id` from MeteringService).
|
||||
*
|
||||
* ```ts
|
||||
* @Driver('puter-chat-completion', {
|
||||
* concurrent: {
|
||||
* default: { limit: 5, backend: 'redis' },
|
||||
* methods: {
|
||||
* complete: {
|
||||
* limit: 5,
|
||||
* bySubscription: { user_free: 1, unlimited: 50 },
|
||||
* backend: 'redis',
|
||||
* },
|
||||
* },
|
||||
* },
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* Methods that don't appear in either `default` or `methods` are unbounded
|
||||
* — matching today's behaviour where nothing is gated.
|
||||
*/
|
||||
concurrent?: DriverConcurrentConfig;
|
||||
/**
|
||||
* When true, `/drivers/call` rejects bare account-session ("root") tokens
|
||||
* for this driver — callers need an app/worker token or a dashboard-minted
|
||||
* API token. See `DriverMeta.noUserSession`.
|
||||
*/
|
||||
/** Reject bare account-session tokens on `/drivers/call`. */
|
||||
noUserSession?: boolean;
|
||||
/**
|
||||
* Subscriber-only methods. `true` accepts any non-free plan; an array of
|
||||
* `SubscriptionPolicy.id`s accepts only those. Methods listed in neither
|
||||
* `methods` nor `default` stay open to every plan — this is opt-in.
|
||||
*
|
||||
* ```ts
|
||||
* @Driver('puter-video-generation', {
|
||||
* requireSubscription: {
|
||||
* methods: {
|
||||
* generate: true,
|
||||
* generateLong: ['business', 'pro'],
|
||||
* },
|
||||
* },
|
||||
* })
|
||||
* ```
|
||||
* `true` accepts any non-free plan; an array of `SubscriptionPolicy.id`s
|
||||
* only those.
|
||||
*/
|
||||
requireSubscription?: DriverRequireSubscriptionConfig;
|
||||
/**
|
||||
* Methods that need a trusted-enough account. Each entry names a tier; what
|
||||
* the tier takes is deployment config (`reputationGate.tiers`), so a driver
|
||||
* declares the standard it holds a method to and not the number. Methods
|
||||
* listed in neither `methods` nor `default` ask for no floor — this is
|
||||
* opt-in.
|
||||
*
|
||||
* ```ts
|
||||
* @Driver('puter-chat-completion', {
|
||||
* requireReputation: {
|
||||
* methods: { complete: 'standard' },
|
||||
* },
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
/** Reputation tier names; thresholds come from `reputationGate.tiers`. */
|
||||
requireReputation?: DriverRequireReputationConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,10 @@ export const KV_CACHED_READ_RATE_SHARE = 0.1;
|
||||
// Microcents per underlying DynamoDB capacity unit, as reported by
|
||||
// SystemKVStore.KVUsage. Cost is `KV_COSTS[op] * usage.<op>`.
|
||||
export const KV_COSTS = {
|
||||
'kv:read': 17,
|
||||
'kv:write': 90,
|
||||
'kv:read': 50,
|
||||
'kv:write': 250,
|
||||
// 10% of `kv:read` — kept as a literal so the reported rate is exactly this
|
||||
// and not a float artifact of the multiplication. The unit count is the one
|
||||
// the equivalent uncached read consumed.
|
||||
'kv:read:cached': 1.7,
|
||||
'kv:read:cached': 5,
|
||||
} as const;
|
||||
+22
-80
@@ -73,13 +73,8 @@ export const DRIVER_REQUIRE_REPUTATION_KEY =
|
||||
|
||||
// -- Driver rate-limit config ----------------------------------------
|
||||
//
|
||||
// A driver declares its rate-limit policy alongside its other metadata
|
||||
// (`@Driver({ rateLimit: ... })` or an imperative `readonly rateLimit`
|
||||
// field). `DriverController.#handleCall` resolves the spec for the
|
||||
// requested method via `resolveDriverMethodRateLimit` and passes it to
|
||||
// `checkDriverRateLimit`. Different driver methods can therefore use
|
||||
// different storage backends and different limits — there's no longer a
|
||||
// single boot-time backend that constrains everyone.
|
||||
// Declared per driver; `DriverController` resolves the spec for the requested
|
||||
// method, so methods can differ in limits and storage backend.
|
||||
|
||||
export const RATE_LIMIT_BACKEND_NAMES = ['memory', 'redis', 'kv'] as const;
|
||||
export type RateLimitBackend = (typeof RATE_LIMIT_BACKEND_NAMES)[number];
|
||||
@@ -89,36 +84,20 @@ export interface DriverRateLimitSpec {
|
||||
limit: number;
|
||||
/** Window length, in milliseconds. */
|
||||
window: number;
|
||||
/**
|
||||
* Per-subscription overrides for `limit`. Keyed by `SubscriptionPolicy.id`
|
||||
* (`user_free`, `temp_free`, `unlimited`, etc.). Falls back to `limit` when
|
||||
* the actor's subscription isn't in the map. Same mechanic as
|
||||
* `DriverConcurrentSpec.bySubscription`.
|
||||
*/
|
||||
/** Per-`SubscriptionPolicy.id` overrides for `limit`. */
|
||||
bySubscription?: Record<string, number>;
|
||||
/**
|
||||
* Storage backend to count against. Omit to use the server-wide default
|
||||
* configured by `config.rate_limit.backend`.
|
||||
*/
|
||||
/** Defaults to `config.rate_limit.backend`. */
|
||||
backend?: RateLimitBackend;
|
||||
}
|
||||
|
||||
export interface DriverRateLimitConfig {
|
||||
/**
|
||||
* Applied to any method not listed in `methods`. Lets a driver opt the
|
||||
* whole interface into tighter limits than the global driver default
|
||||
* without enumerating every method.
|
||||
*/
|
||||
/** Applied to any method not listed in `methods`. */
|
||||
default?: DriverRateLimitSpec;
|
||||
/** Per-method overrides. Keys are driver method names. */
|
||||
methods?: Record<string, DriverRateLimitSpec>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a `rateLimit` block declared by a driver. Throws on bad shape so
|
||||
* registration fails loudly at boot rather than silently misconfiguring
|
||||
* production traffic. Returns the value unchanged on success for chaining.
|
||||
*/
|
||||
/** Validate a driver's `rateLimit` block; throws at boot on a bad shape. */
|
||||
export function validateDriverRateLimit(
|
||||
value: unknown,
|
||||
label: string,
|
||||
@@ -207,25 +186,13 @@ export function resolveDriverMethodRateLimit(
|
||||
}
|
||||
|
||||
// -- Driver concurrent-limit config ----------------------------------
|
||||
//
|
||||
// Twin to `DriverRateLimitConfig` but for in-flight concurrency. Same
|
||||
// shape minus `window`, plus `bySubscription` to vary the cap by the
|
||||
// caller's subscription tier (resolved via `MeteringService`).
|
||||
|
||||
export interface DriverConcurrentSpec {
|
||||
/** Maximum simultaneous in-flight requests. */
|
||||
limit: number;
|
||||
/**
|
||||
* Per-subscription overrides keyed by `SubscriptionPolicy.id` (`user_free`,
|
||||
* `temp_free`, `unlimited`, etc.). Falls back to `limit` when the actor's
|
||||
* subscription isn't in the map.
|
||||
*/
|
||||
/** Per-`SubscriptionPolicy.id` overrides for `limit`. */
|
||||
bySubscription?: Record<string, number>;
|
||||
/**
|
||||
* Storage backend. Memory is per-process (use only on single-node
|
||||
* deployments); redis coordinates across nodes; kv is rarely the right
|
||||
* choice for concurrency but supported for parity.
|
||||
*/
|
||||
/** `memory` is per-process; use `redis` on multi-node deployments. */
|
||||
backend?: RateLimitBackend;
|
||||
}
|
||||
|
||||
@@ -442,59 +409,34 @@ export function resolveDriverMethodRequireReputation(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved metadata for a registered driver. Read from either decorator
|
||||
* metadata or imperative instance properties.
|
||||
* Resolved metadata for a registered driver, from decorator metadata or
|
||||
* imperative instance properties. The gates live here rather than on a route
|
||||
* because every driver shares the `/drivers/call` dispatch route.
|
||||
*/
|
||||
export interface DriverMeta {
|
||||
/** The interface this driver implements (e.g. 'puter-chat-completion'). */
|
||||
/** E.g. 'puter-chat-completion'. */
|
||||
interfaceName: string;
|
||||
/** Unique name within its interface (e.g. 'openai-completion', 'claude'). */
|
||||
/** Unique within the interface. */
|
||||
driverName: string;
|
||||
/** When true, this driver is the default for its interface. */
|
||||
isDefault: boolean;
|
||||
/**
|
||||
* Additional driver names that resolve to the same instance. Used by
|
||||
* multi-provider drivers (TTS/OCR/image/video) so legacy puter-js calls
|
||||
* that pass a provider id in the `driver` slot (e.g. `aws-polly`,
|
||||
* `openai-tts`) still find the unified driver. The requested alias is
|
||||
* exposed to the driver method via `Context.get('driverName')` so the
|
||||
* method can route to the right internal provider.
|
||||
* Other names resolving to this driver, so legacy calls naming a provider
|
||||
* (`aws-polly`) still find the unified driver. The requested alias reaches
|
||||
* the method via `Context.get('driverName')`.
|
||||
*/
|
||||
aliases: string[];
|
||||
/**
|
||||
* Rate-limit policy for this driver. Per-method specs override the
|
||||
* `default` spec; both are optional. `DriverController` consults this
|
||||
* before invoking the method and falls back to the global driver default
|
||||
* (600/min) if nothing is declared.
|
||||
*/
|
||||
/** Falls back to the global driver default when absent. */
|
||||
rateLimit?: DriverRateLimitConfig;
|
||||
/**
|
||||
* Concurrent in-flight policy for this driver. When set, the controller
|
||||
* acquires a slot before invoking the method and releases in `finally`.
|
||||
* Absent → no concurrency cap (current behaviour).
|
||||
*/
|
||||
/** No concurrency cap when absent. */
|
||||
concurrent?: DriverConcurrentConfig;
|
||||
/**
|
||||
* When true, `/drivers/call` rejects bare account-session ("root") tokens
|
||||
* for this driver: callers must present an app/worker token or a
|
||||
* dashboard-minted API token. Per-driver counterpart of the `noUserSession`
|
||||
* route option — the dispatch route is shared, so the flag lives on the
|
||||
* driver.
|
||||
*/
|
||||
/** Reject bare account-session tokens. */
|
||||
noUserSession?: boolean;
|
||||
/**
|
||||
* Which methods on this driver need a subscription, and which plans count.
|
||||
* Per-method entries override `default`; a method covered by neither stays
|
||||
* open to every plan. Per-driver for the same reason as `noUserSession` —
|
||||
* the dispatch route is shared.
|
||||
* Per-method plan requirement; a method covered by neither `default` nor
|
||||
* `methods` is open.
|
||||
*/
|
||||
requireSubscription?: DriverRequireSubscriptionConfig;
|
||||
/**
|
||||
* Which methods on this driver need a reputation tier, and which tier.
|
||||
* Per-method entries override `default`; a method covered by neither asks
|
||||
* for no floor. Per-driver for the same reason as `requireSubscription` —
|
||||
* the dispatch route is shared.
|
||||
*/
|
||||
/** Per-method reputation tier; same coverage rule as `requireSubscription`. */
|
||||
requireReputation?: DriverRequireReputationConfig;
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user