Compare commits

..
Author SHA1 Message Date
jelveh 1cc00fa977 refactor(gui): remove the AI side panel and its desktop button
The desktop no longer launches the `ai` app at boot. This removes the
`is_panel` window mode, which existed only for that launch, and the
toolbar/taskbar/window/popover offsets tied to it. It also removes the
desktop sparkles button that showed the panel, and the
`silent_on_failure` launch option, whose only caller was the boot launch.
"Open in AI" in the item context menu still launches the app as a
normal window.
2026-10-20 15:19:45 -07:00
903 changed files with 28094 additions and 118843 deletions

No files matched your search

@@ -22,7 +22,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '26'
node-version: '24'
cache: 'npm'
- name: Install dependencies
@@ -47,13 +47,3 @@ jobs:
PUTER_TEST_AI_MOONSHOT_API_KEY: ${{ secrets.PUTER_TEST_AI_MOONSHOT_API_KEY }}
PUTER_TEST_AI_ZAI_API_KEY: ${{ secrets.PUTER_TEST_AI_ZAI_API_KEY }}
PUTER_TEST_AI_ELEVENLABS_API_KEY: ${{ secrets.PUTER_TEST_AI_ELEVENLABS_API_KEY }}
# Realtime conversations through the SDK against live OpenAI. Builds
# the SDK bundle these tests load itself, so it runs even when the
# step above fails; the tests skip themselves without the key.
- name: Run live realtime conversation tests
if: ${{ !cancelled() }}
run: npm run setupExtensions && (cd src/puter-js && npm run build) && npx vitest run --config src/puter-js/tests/api/vitest.config.ts src/puter-js/tests/api/realtime/convoLive.test.ts
env:
CI: 'true'
PUTER_TEST_AI_OPENAI_API_KEY: ${{ secrets.PUTER_TEST_AI_OPENAI_API_KEY }}
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '26'
node-version: '24'
cache: 'npm'
- name: Install dependencies
@@ -88,7 +88,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '26'
node-version: '24'
cache: 'npm'
- name: Install dependencies
@@ -154,7 +154,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '26'
node-version: '24'
cache: 'npm'
- name: Install dependencies
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '26'
node-version: '24'
cache: 'npm'
- name: Install dependencies
-48
View File
@@ -1,48 +0,0 @@
name: Notify HeyPuter (dev)
on:
workflow_dispatch:
inputs:
puter_ref:
description: "puter branch, tag or SHA"
type: string
default: "main"
permissions:
contents: read
jobs:
authorize:
if: github.repository == 'HeyPuter/puter'
runs-on: ubuntu-latest
steps:
- name: Require maintain or admin
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.triggering_actor }}
run: |
ROLE=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" --jq .role_name)
if [ "$ROLE" != "maintain" ] && [ "$ROLE" != "admin" ]; then
echo "::error::$ACTOR has role '$ROLE'; maintain or admin required"
exit 1
fi
notify:
needs: authorize
runs-on: ubuntu-latest
# Holds HEYPUTER_DISPATCH_TOKEN; limited to main so branch copies of this file can't read it.
environment: heyputer-dispatch
steps:
- name: Trigger heyputer dev deploy
env:
PUTER_REF: ${{ inputs.puter_ref }}
TOKEN: ${{ secrets.HEYPUTER_DISPATCH_TOKEN }}
run: |
jq -nc --arg ref "$PUTER_REF" \
'{event_type: "puter-dev-requested", client_payload: {puter_ref: $ref}}' |
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/HeyPuter/heyputer/dispatches \
-d @-
echo "Requested \`$PUTER_REF\`" >> "$GITHUB_STEP_SUMMARY"
+1 -2
View File
@@ -8,7 +8,6 @@ on:
jobs:
notify:
runs-on: ubuntu-latest
environment: heyputer-dispatch
steps:
- name: Trigger heyputer build
run: |
@@ -16,4 +15,4 @@ jobs:
-H "Authorization: token ${{ secrets.HEYPUTER_DISPATCH_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/HeyPuter/heyputer/dispatches \
-d '{"event_type":"puter-main-updated","client_payload":{"puter_ref":"main"}}'
-d '{"event_type":"puter-main-updated","client_payload":{"puter_ref":"main"}}'
+4 -8
View File
@@ -57,7 +57,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '26'
node-version: '24'
cache: 'npm'
- name: Install dependencies
@@ -78,7 +78,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '26'
node-version: '24'
cache: 'npm'
- name: Install dependencies
@@ -118,13 +118,9 @@ jobs:
fail-fast: false
matrix:
include:
# The base branch lives in this repo; for a fork PR, the head
# repo's copy of it can be arbitrarily stale.
- ref: ${{ github.base_ref }}
repository: ${{ github.repository }}
artifact: base
- ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
artifact: pr
steps:
@@ -133,13 +129,13 @@ jobs:
uses: actions/checkout@v7
with:
ref: ${{ matrix.ref }}
repository: ${{ matrix.repository }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Set up Node.js
if: needs.changes.outputs.puterjs == 'true'
uses: actions/setup-node@v7
with:
node-version: '26'
node-version: '24'
cache: 'npm'
- name: Install dependencies
+2 -4
View File
@@ -184,12 +184,10 @@ Recipes are tested, copy-pasteable patterns for a task, one layer above the per-
- **Description.** Written for someone who has never heard of Puter. Start with "Learn how to", put the action first, and say what the reader's app gains: "Learn how to add file sharing to your app with Puter.js, so users can share files in their Puter account with other people." No API names, no mechanism, no list of example cases, and no "not just …" contrast.
- **Intro.** Explain the situation and why you'd do this, tied to something the reader already knows ("`puter.kv` is your database in Puter.js"). No "Use this when" bullet list.
- **Structure.** Get to the first working call as early as possible, then add validation, rate limits, and sign-in after it. Explain a Puter concept the first time it appears, such as what `me.puter` is or that `puter.workers.exec()` opens the Puter sign-in window. Split recipes by what the reader needs, not by technique, and give a family of recipes a shared tag.
- **Reading order.** A recipe reads top to bottom. Each code block uses only functions and variables from blocks above it, so the reader never has to search further down the page to understand it. Start with a small version that works, and have each section add one case. When a section changes a function from earlier, show the whole new version of that function there. If the code is built up over several sections, end with a `## Full Code` block that has all of it.
- **Link to the reference for full option lists.** Mention only the options the recipe uses, then link to the function page for the rest ("All the sort options are listed on [`puter.fs.readdir()`](/FS/readdir/)."). A copied list of options, rules, or scopes goes stale when the reference changes.
- **Limits.** Mention a limit only when it's the reason for a pattern the recipe uses, such as one wildcard subscription instead of one per key, and link to [rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) or the function page for the number. Leave out limits that code following the recipe never reaches.
- **Code.** Platform-neutral: just the code, with top-level `await`. No `<script>` or `<html>` wrapper, no IIFE.
- **Tradeoffs.** Justify a choice with one scenario the reader can picture right away, in about two short paragraphs, or fold each point into the section where it matters. The description states the benefit only.
- **Notes.** Leave out `## Notes` by default. Use it only to point to an architecturally different approach. A limit the recipe needs goes in the section it applies to, and other facts belong on the linked reference page.
- **Notes.** Leave out `## Notes` by default. Use it only to point to an architecturally different approach. A limit goes in the section it applies to; other facts belong on the linked reference page.
---
@@ -201,6 +199,6 @@ Recipes are tested, copy-pasteable patterns for a task, one layer above the per-
- **Reuse existing UI primitives before writing new ones.** Windows and dialogs are `UIWindow*` functions in [src/gui/src/UI/](src/gui/src/UI/); generic pieces already exist (`UIAlert`, `UIPrompt`, `UIContextMenu`, `UINotification`, `UIPopover`, widgets in [UI/Components/](src/gui/src/UI/Components/)). A new window should read like its neighbors: an async function taking an options object, composing an HTML string, wiring behavior with jQuery, delegating to `UIWindow(...)`.
- **Match the visual language.** Use existing CSS classes (`button`, `button-primary`, window chrome, form styles) and copy the layout patterns of neighboring windows; new styles go in [src/gui/src/css/](src/gui/src/css/) following existing conventions. Verify anything positional (menus, overlays, z-index) on both desktop and mobile viewports.
- **i18n every user-facing string.** No hardcoded UI text — use `i18n('key')` and add the key to [src/gui/src/i18n/translations/en.js](src/gui/src/i18n/translations/en.js).
- **i18n every user-facing string.** No hardcoded UI text — use `i18n('key')` and add the key to [src/gui/src/i18n/translations/en.js](src/gui/src/i18n/translations/en.js). Run `npm run check-translations` before opening the PR.
- **Shared logic goes in helpers/services.** Reusable non-UI logic belongs in [src/gui/src/helpers/](src/gui/src/helpers/) or [src/gui/src/services/](src/gui/src/services/), not copy-pasted between windows.
- **Tests.** Vitest is wired for the GUI (`src/**/*.test.js`; see [appOrder.test.js](src/gui/src/UI/Dashboard/appOrder.test.js) for the shape). Extract pure logic into functions and test those. Desktop behavior driven through puter.js (`puter.ui.*`) is covered by the Playwright harness in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/) — add a spec there when you change how the desktop renders SDK-driven UI.
+3 -9
View File
@@ -14,7 +14,7 @@
# config.default.json, so partial overrides work. Absent file = defaults.
# ---- Build stage ----
FROM node:26-bookworm-slim AS build
FROM node:24-slim AS build
WORKDIR /opt/puter
@@ -59,7 +59,7 @@ RUN set -e; \
wait $pjs_pid
# ---- Runtime stage (slim — no build tools) ----
FROM node:26-bookworm-slim
FROM node:24-slim
WORKDIR /opt/puter
@@ -73,12 +73,6 @@ COPY --from=build --chown=node:node /opt/puter .
RUN mkdir -p /etc/puter /var/puter && \
chown -R node:node /etc/puter /var/puter
# Fix outdated HOME_URL in /etc/os-release (see issue #4329)
# The base image may contain a reference to the old phoenix repository.
RUN if [ -f /etc/os-release ]; then \
sed -i 's|HOME_URL="https://github.com/HeyPuter/phoenix"|HOME_URL="https://github.com/HeyPuter/puter"|' /etc/os-release; \
fi
# Self-hosters mount their override at this exact path. The v2 loader
# deep-merges it over config.default.json (see backend/index.ts).
ENV PUTER_CONFIG_PATH=/etc/puter/config.json
@@ -93,4 +87,4 @@ USER node
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
CMD ["node", "-r", "./dist/src/backend/telemetry.js", "./dist/src/backend/index.js"]
CMD ["node", "-r", "./dist/src/backend/telemetry.js", "./dist/src/backend/index.js"]
+2 -12
View File
@@ -215,9 +215,7 @@
// "bootstrapTables": true,
"path": "",
// Credentials. NOTE: snake_case here, unlike `s3.s3Config` below.
// For dynamodb-local, any non-empty values work. On AWS,
// `"useCredentialChain": true` uses the default credential chain
// instead of keys.
// For dynamodb-local, any non-empty values work.
"aws": {
"access_key": "",
"secret_key": "",
@@ -315,8 +313,7 @@
// The tables ship either way and sit inert; this gates whether `/teams`
// is registered at all, so with it off the paths do not exist rather than
// existing and refusing. It is also the backout: turning it off removes
// the feature without touching data. Teams ships as an extension; on,
// the server refuses to boot unless one registers it.
// the feature without touching data.
"teams_enabled": false,
//
// Live teams one user may own. Soft-deleted ones do not count, so
@@ -350,9 +347,6 @@
// not, a row past this is swept. Off by default; any value above 0 arms
// the sweep.
// "notificationRetentionDays": 14,
//
// Ms a live notification waits for open tabs to acknowledge it.
// "notificationAckTimeoutMs": 5000,
// ── Alarms / alerting ───────────────────────────────────────────────
// Where system alarms go. Severity is the routing decision — each
@@ -436,8 +430,6 @@
"mistral": { "apiKey": "" },
"xai": { "apiKey": "" },
"moonshot": { "apiKey": "" },
// MiniMax. One key powers chat and video generation (H3, Hailuo);
// video calls go to the same host, beside the chat API's `/v1`.
"minimax": {
"apiKey": "",
"apiBaseUrl": "https://api.minimax.io/v1"
@@ -500,8 +492,6 @@
"defaultVoiceId": "",
"speechToSpeechModelId": ""
},
// Both AWS providers also take `"useCredentialChain": true` in place
// of keys, like `dynamo.aws`.
"aws-polly": {
"access_key": "",
"secret_key": "",
+1 -3
View File
@@ -45,9 +45,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
- **A specific verified factor:** `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. Opt-in, and the factor must actually have been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for. `requireAnyVerified: ['phone', 'card']` is the OR of the last two: any listed factor verified at any point passes, as does a paid plan when `card` is listed (a paying account already has a card on file). Otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on); with none verifiable the gate is inert rather than locking the route on a self-hosted install. A denial carries the first verifiable factor's code plus `factors` (the verifiable ones, in the route's order), so a client can lead with one flow and offer the other. `verifiedFactorGate.enabled: false` in config stops every declared gate at once, e.g. during an SMS-provider outage.
- **Metered spend:** an endpoint that spends metered resources on the caller's behalf (moving file content, object-store requests, anything else the account is billed for) declares `requireCredits: true`, which turns an account with no budget left away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to see what it has, clear it, and reach its billing pages. Drivers have no route options, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
Provider sign-in requires the account's authenticator or a recovery code when 2FA is enabled, including signup callbacks that resolve an existing account. The five-minute challenge uses the same single-use `/login/otp` and `/login/recovery-code` verification as password sign-in. The GUI carries it in a URL fragment and removes it before initialization. A session and successful popup-return proof are issued only after verification; cancelling retains the popup's signed retry context without marking it authenticated.
Disabling 2FA through `/user-protected/disable-2fa` requires both password or OIDC revalidation and either the current six-digit authenticator `code` or an unused recovery code in `code`. Recovery codes are consumed atomically. Missing or incorrect codes leave the secret and remaining recovery codes intact. A team's requirement for 2FA still prevents disabling it.
Disabling 2FA through `/user-protected/disable-2fa` requires both password or OIDC revalidation and the current six-digit authenticator `code`. Missing, malformed, or incorrect codes leave the secret and recovery codes intact. A team's requirement for 2FA still prevents disabling it.
Email changes through `/user-protected/change-email` require the password or OIDC revalidation cookie, plus a six-digit `code` from the authenticator when 2FA is enabled. Missing or malformed codes return `400 bad_request`; incorrect codes return `401 code_mismatch`. Both include `factor: 'otp'`. Confirmation links are bound to that verified request. A 2FA-enabled account must request a new link if its existing link was issued without second-factor verification.
+1 -8
View File
@@ -51,18 +51,11 @@ two pages can't be decoded into how fast the table grows; `openCursor` still
reads a plain cursor. What a cursor wraps is an implementation detail per
store:
- DynamoDB-backed lists wrap `LastEvaluatedKey`. A kv listing of another
namespace (cross-app or through a share handle) resumes after the last entry
it returned instead, since the scan can stop on a private one, and its cursor
is sealed to that listing and padded to one length.
- DynamoDB-backed lists wrap `LastEvaluatedKey`.
- SQL-backed lists wrap a keyset position — `(sortValue, id)` of the last
row — and the query seeks past it (`WHERE (col, id) > (?, ?) ORDER BY col,
id`). Any SQL list gaining a cursor must have a deterministic `ORDER BY`
ending in a unique tiebreaker (`id`).
- The notification catch-up (`GET /events/fetch`) holds two keysets in one
sealed cursor: the last `id` it returned, and the last `(replaced_at, id)`
among rows up to that id. Each is its own indexed read, merged into one
page, so a row replaced after a client read it comes back from that cursor.
Cursors that carry a sort also pin it: a request that passes a cursor plus a
conflicting sort is rejected with 400.
-79
View File
@@ -1,79 +0,0 @@
# Browser push client
Puter's desktop shows pushes through `/sw.js`. Core registers it, and shows Settings → Notifications, only when the `pushEnabled` gui param is `true`; core never sets it. A backend extension that delivers pushes sets it with `services.homepage.setGuiParam('pushEnabled', true)` and can load its own code into the worker with `services.homepage.registerServiceWorkerScript(path)`.
Core subscribes the browser to a push service only when the server also publishes `pushWebPushKey` (see [Built-in Web Push client](#built-in-web-push-client)), and it has no `/push/*` routes of its own. The device, app, preference and subscription endpoints below come from that extension. On a deployment without one, such as most self-hosted servers, they answer core's plain 404 (`code: 'not_found'`), which `puter.notifications` reports as `push_unavailable` and the Notifications tab shows as unavailable. A device the extension can't find is `device_not_found`.
## Push payload
`sw.js` reads the push's JSON body. The message is its `puter` field, or `data.puter` for services that only carry string data, as an object or a JSON string:
```ts
{ uid?: string; title?: string; body?: string; icon?: string; group?: string;
target?: { app?: string; path?: string }; data?: unknown; channel?: string;
type?: 'challenge'; challenge?: string }
```
`uid` is the notification's uid, a later push with the same `group` replaces the one showing, and `target` and `data` reach the app when the user taps it.
When the browser replaces the subscription (`pushsubscriptionchange`), `sw.js` posts `{ type: 'puter-push-subscription-changed' }` to every open window on Puter's origin. The worker has no token or session, so the page's push client registers the new subscription.
## Receipts
Before a notification is pushed, the server asks the open clients whether they received it live. It sends `notif.receipt` with `{ uid }` on the user's sockets for the app that sent it, or on the desktop's sockets for Puter's own notifications. The SDK remembers the uids its page received through `onNotification()`, `onPush()` or the desktop's feed, and answers once its page has that notification, or with `{ received: false }` when it never gets it. The server waits up to 5 seconds (`notificationAckTimeoutMs`).
An answer carries `visibilityState`, `focused` and `showsOwn`, the last being whether the page shows the notification itself. A website page shows it itself when it is hidden or unfocused, has an `onPush()` handler and notification permission, and no other tab of the same site is focused. Inside the desktop, the desktop shows it, and an app's `onPush()` result is never shown as a browser notification.
- An answer from a page that is visible and focused, or that shows the notification itself, stops the push.
- Otherwise the notification is pushed. A website's pushes go only to the browsers that subscribed through its own worker, never to Puter's `/sw.js`. An app inside the desktop pushes through `/sw.js`, which shows the app's name and icon.
- A hidden desktop tab shows no banner, so answers only from hidden desktop tabs don't stop a push of Puter's own notifications.
- A client that can't answer, such as an older puter.js, holds the push back while it is connected.
## Registering
The registry is in [src/gui/src/helpers/pushClient.js](../src/gui/src/helpers/pushClient.js). A service script registers its client like this:
```js
service_script(async (api) => {
api.use()['push.registerPushClient']?.(client);
});
```
`use(id)` throws when nothing is registered under `id`, so the form above also runs on a Puter version without the registry and does nothing there. Any script that runs after the GUI bundle can call `use()['push.registerPushClient']?.(client)` the same way. Registering again replaces the client. Registering dispatches `push-client-registered` on `window`, and an open Notifications tab redraws.
With no client registered, the tab says push notifications aren't available in this browser.
### Built-in Web Push client
When the server also sets the `pushWebPushKey` gui param (a VAPID public key), core registers its own client ([webPushClient.js](../src/gui/src/helpers/webPushClient.js)) unless an extension registered one first. It uses `puter.notifications.enable()` / `disable()`, the same standard Web Push flow apps use on their own origins:
1. `GET /push/web/key` for the key, then `pushManager.subscribe()` on the `/sw.js` registration.
2. `POST /push/web/subscriptions { subscription }`. A subscription the server already has answers `{ status: 'active' }`. A new one answers `{ status: 'pending' }`, and the server sends it a push with `{ type: 'challenge', challenge }`.
3. The worker shows that push and posts `{ type: 'puter-push-challenge', challenge }` to the origin's windows; the page sends it to `POST /push/web/subscriptions/verify`. The device is registered once the page hands the confirmation back.
4. `DELETE /push/web/subscriptions { endpoint }` removes it.
Apps on their own origin do the same through `push-sw.js`, which they import into their own worker. A subscription made with an app token is the app's: its pushes are that app's notifications only.
## Interface
```ts
interface PushClient {
enable(): Promise<{ deviceUid: string }>;
disable(): Promise<void>;
status(): Promise<{
permission: 'default' | 'granted' | 'denied';
registered: boolean;
deviceUid?: string;
}>;
}
```
- `enable()` may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includes `notifications:push` while this browser is off, and the "Turn on notifications" button of the `/action/enable-push` popup and the matching desktop dialog (`UIEnablePushDialog`). Core also calls it without a click, from `helpers/pushSync.js`, when `Notification.permission` is already `granted`, `status()` reports the browser as not registered, the user has not turned push off in this browser, and an app holds `notifications:push`; the browser does not prompt then. The built-in client runs `puter.notifications.enable()`, which subscribes with the `/sw.js` registration and registers the browser through the Web Push flow above (`platform: 'webpush'`). It then resolves the `uid` of this browser's `webpush` device from `listDevices()`. It rejects when permission is refused or the browser wasn't registered.
- `disable()` runs `puter.notifications.disable()`, which sends `DELETE /push/web/subscriptions { endpoint }` and ends the browser's subscription. The tab calls it for Turn off, and for Remove on the device marked as this browser. Remove on any other device is `DELETE /push/devices/:uid`.
- `status()` reports the state without prompting. `permission` is `Notification.permission`. `registered` is true when this browser has a device registered for the signed-in user, and `deviceUid` is that device's `uid`.
After `enable()` resolves, the tab calls `POST /push/devices/:uid/test` and asks the user whether a notification showed up.
The tab also reads `GET /push/apps` and `GET`/`POST /push/preferences`.
Core keeps its own record of a browser the user turned off, `puter.push.browser-off:<user uuid>` in `localStorage`, written by Turn off and by Remove on this browser's device, and cleared when `enable()` resolves. The silent registration above skips a browser with that record, so a device removed here stays removed while one removed from another device, or a new sign-in in a browser that already allows notifications, registers again.
+1 -19
View File
@@ -12,17 +12,7 @@ Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker
## Requirements
- **Docker** with the `compose` plugin. This is the one thing you have to install
yourself — the installer checks for it and stops with an explanation rather
than installing it for you.
- **macOS / Windows:** [Docker Desktop](https://docs.docker.com/get-docker/).
Both ship the `compose` plugin.
- **Linux:** [Docker Engine](https://docs.docker.com/engine/install/) plus the
[`docker-compose-plugin`](https://docs.docker.com/compose/install/) package.
- Confirm before running anything else: `docker compose version` should print a
version, not `command not found`.
- If Docker is installed but the shell can't see it, make sure your user is in
the `docker` group (Linux) or that Docker Desktop is actually running.
- **Docker** with the `compose` plugin.
- A **domain** with DNS access — you need a wildcard record (`*.your-domain.com` → server IP). Puter routes by subdomain (`api.<domain>`, `site.<domain>`, `app.<domain>`).
- Optional: **TLS certs** (or `certbot` to grab them — see Step 3).
@@ -667,14 +657,6 @@ Migrations re-apply idempotently across pulls. Volumes are preserved.
## Troubleshooting
**`missing required command: docker` — the installer stops immediately.**
Docker isn't on your `PATH` yet. Puter self-hosting is a Docker Compose stack, so
nothing can run without it. Install [Docker Desktop](https://docs.docker.com/get-docker/)
on macOS/Windows or [Docker Engine](https://docs.docker.com/engine/install/) plus
`docker-compose-plugin` on Linux, then confirm `docker compose version` works in
the same shell and re-run the installer. On Linux, a fresh Docker install usually
needs a new login session before your user picks up the `docker` group.
**Site loads but I get a 502 / "Bad Gateway" from Caddy.**
The puter container failed to come up. `docker compose logs puter` will tell you which dependency rejected it (most often DB password mismatch between `.env` and `config.json`).
-35
View File
@@ -314,41 +314,6 @@ describe('thumbnails extension — handleThumbnailRead', () => {
await server?.shutdown();
});
it('keeps read URLs stable within an hour and rotates them at the next hour', async () => {
const key = mintedKey();
const signedAt = Date.UTC(2026, 9, 26, 10);
const read = async () => {
const entry: Record<string, unknown> = {
thumbnail: `s3://${BUCKET}/${key}`,
};
await handleThumbnailRead(entry, {
s3,
s3Presign,
bucketName: BUCKET,
bucketEndpoint: BUCKET_ENDPOINT,
fsEntry: server.stores.fsEntry,
});
return entry.thumbnail as string;
};
vi.useFakeTimers({ toFake: ['Date'] });
try {
vi.setSystemTime(signedAt + 1000);
const first = await read();
vi.setSystemTime(signedAt + 3599_000);
expect(await read()).toBe(first);
expect(new URL(first).searchParams.get('X-Amz-Date')).toBe(
'20261026T100000Z',
);
expect(new URL(first).searchParams.get('X-Amz-Expires')).toBe(
'604800',
);
vi.setSystemTime(signedAt + 3600_000);
expect(await read()).not.toBe(first);
} finally {
vi.useRealTimers();
}
});
// Thumbnails stored before keys were bound to entries keep displaying.
it.each([
['an entry-bound key', () => mintedKey()],
+2 -5
View File
@@ -307,9 +307,6 @@ export const handleThumbnailRead = async (
const thumb = entry.thumbnail;
if (typeof thumb !== 'string' || !thumb) return;
const presignClient = deps.s3Presign;
const signingDate = new Date(
Math.floor(Date.now() / 3_600_000) * 3_600_000,
);
if (isStoragePointer(thumb, deps.bucketEndpoint)) {
const key = resolveThumbnailKey(thumb);
@@ -322,7 +319,7 @@ export const handleThumbnailRead = async (
entry.thumbnail = await getSignedUrl(
presignClient,
new GetObjectCommand({ Bucket: deps.bucketName, Key: key }),
{ expiresIn: 604800, signingDate },
{ expiresIn: 604800 },
);
} else if (thumb.startsWith('data')) {
// Move an inline thumbnail into storage. The row only takes the new
@@ -376,7 +373,7 @@ export const handleThumbnailRead = async (
entry.thumbnail = await getSignedUrl(
presignClient,
new GetObjectCommand({ Bucket: deps.bucketName, Key: key }),
{ expiresIn: 604800, signingDate },
{ expiresIn: 604800 },
);
}
};
+25 -13
View File
@@ -1,7 +1,13 @@
import type { Request, Response } from 'express';
import { installSeatStub } from '@heyputer/backend/testFixtures/seatStub';
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
afterAll,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import { makeActor, type Actor } from '../src/backend/core/actor.ts';
import { runWithContext } from '../src/backend/core/context.ts';
import { configContainer } from '../src/backend/exports.ts';
@@ -43,6 +49,7 @@ beforeAll(async () => {
create_shortcut: true,
payment_bypass: true,
},
teams_enabled: true,
} as never);
});
@@ -76,20 +83,21 @@ describe('whoami extension — handleWhoami', () => {
};
const seatOf = async (teamName: string) => {
const owner = await seedUser();
const seat = await seedSeat();
const team = {
uid: `wt-${Math.random().toString(36).slice(2, 9)}`,
const team = await server.stores.team.create({
ownerUserId: owner.id as number,
name: teamName,
};
installSeatStub(server as never, [seat.id as number], {
teamUid: team.uid,
teamName: team.name,
handle: `wt-${Math.random().toString(36).slice(2, 9)}`,
});
await server.stores.team.addMember(team.uid, seat.id as number, {
orgOwned: true,
});
return { seat, team };
};
it('names no team where teams is off', async () => {
const { seat } = await seatOf('Acme Corp');
it('names the team for a seat', async () => {
const { seat, team } = await seatOf('Acme Corp');
const { res, captured } = makeRes();
await runWithContext(
@@ -97,8 +105,10 @@ describe('whoami extension — handleWhoami', () => {
() => handleWhoami(makeReq(), res),
);
// Off, so the seat is never looked up.
expect((captured.body as { team?: unknown }).team).toBeUndefined();
expect((captured.body as { team?: unknown }).team).toEqual({
uid: team.uid,
name: 'Acme Corp',
});
});
it('says nothing for an account that is not a seat', async () => {
@@ -516,7 +526,9 @@ describe('whoami extension — handleWhoami', () => {
}),
]) {
const { res, captured } = makeRes();
await runWithContext({ actor }, () => handleWhoami(makeReq(), res));
await runWithContext({ actor }, () =>
handleWhoami(makeReq(), res),
);
const body = captured.body as Record<string, unknown>;
expect(body.phone).toBeUndefined();
+1 -21
View File
@@ -53,29 +53,9 @@ need() {
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
}
check_docker() {
if command -v docker >/dev/null 2>&1; then
return 0
fi
echo >&2 ""
echo >&2 "[puter-install] missing required command: docker"
echo >&2 ""
echo >&2 "Docker is required to run Puter self-hosted."
echo >&2 ""
echo >&2 "Installation instructions:"
echo >&2 " - macOS (Apple Silicon & Intel): https://docs.docker.com/desktop/setup/install/mac-install/"
echo >&2 " (Docker Desktop is recommended. Alternatives: OrbStack, Colima)"
echo >&2 " - Linux: https://docs.docker.com/engine/install/"
echo >&2 " - Windows: https://docs.docker.com/desktop/setup/install/windows-install/"
echo >&2 ""
echo >&2 "After installing Docker, make sure it's running and try again."
echo >&2 ""
exit 1
}
# ── Step 1: dependency check ────────────────────────────────────────
log "checking dependencies"
check_docker
need docker
need curl
need openssl
docker compose version >/dev/null 2>&1 \
+837 -867
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -54,6 +54,7 @@
"dev": "npm start",
"build": "npm run setupExtensions && npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
"build:workerLib": "cd src/puter-js && npm run build && cd ../worker && npm run build",
"check-translations": "node tools/check-translations.js",
"prepare": "husky",
"build:ts": "tsc -p tsconfig.build.json && node ./tools/write-dist-package-json.mjs",
"check:puterjs:types": "node tools/checkPuterjsTypes.mjs",
@@ -78,8 +79,7 @@
"javascript-time-ago": "^2.5.11",
"libphonenumber-js": "1.13.15",
"miniflare": "^4.20260617.1",
"open": "^10.1.0",
"sharp": "^0.35.5"
"open": "^10.1.0"
},
"engines": {
"node": ">=24.0.0"
-135
View File
@@ -1,135 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { PuterServer } from './server.ts';
import { createTestUser, setupTestServer } from './testUtil.ts';
/**
* Teams is optional, so core has to work when neither the service nor the store
* is registered, which is how this test server boots.
*/
describe('core without a teams registration', () => {
let server: PuterServer;
const makeUser = async (): Promise<{ id: number; username: string }> => {
const username = `nt_${Math.random().toString(36).slice(2, 10)}`;
const created = (await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
})) as unknown as { id: number };
return { id: created.id, username };
};
beforeAll(async () => {
server = await setupTestServer({
// Without this the allowlist check returns before asking Teams.
teams_allowed_email_domains: ['test.local'],
} as never);
});
afterAll(async () => {
await server?.shutdown();
});
it('has no teams in the registry', () => {
expect(server.services.team).toBeUndefined();
expect(server.stores.team).toBeUndefined();
});
it('refuses to boot when the config asks for teams', async () => {
await expect(
setupTestServer({ teams_enabled: true } as never),
).rejects.toThrow(/teams_enabled/);
});
it('deletes an account without asking what it was charged for', async () => {
const user = await makeUser();
await expect(
server.services.userAccount.cascadeDelete(user.id),
).resolves.not.toThrow();
expect(await server.stores.user.getById(user.id)).toBeFalsy();
});
it('answers the address-claim question signup asks', async () => {
const user = await makeUser();
const row = (await server.stores.user.getById(user.id))!;
// Nothing is a seat, so the holder is returned as-is.
await expect(
server.services.signup.signupEmailHolder(row.email as string, {
releaseSeat: true,
}),
).resolves.toMatchObject({ id: user.id });
});
it('lists what is shared with an account, folding in no team shares', async () => {
const user = await makeUser();
await expect(
server.services.share.listSharedWithMe({
user: { id: user.id },
} as never),
).resolves.toMatchObject({ items: [] });
});
it('refuses a share aimed at a team, rather than failing', async () => {
// A real home directory, so the refusal is the recipient's and not
// the entry's.
const username = `nt_${Math.random().toString(36).slice(2, 10)}`;
const creds = await createTestUser(server, {
username,
password: 'Nt-Pw-12345!',
});
const row = (await server.stores.user.getByUsername(username))!;
const user = { id: row.id, username };
const actor = { user: row } as never;
expect(creds).toBeTruthy();
// The recipient cannot exist, so this is a 404 and not a crash.
await expect(
server.services.share.share(actor, {
recipient: { teamHandle: 'nobody' },
mode: 'read',
path: `/${user.username}`,
} as never),
).rejects.toMatchObject({ statusCode: 404 });
});
it('serves the homepage shell with the teams tab off', async () => {
const user = await makeUser();
const row = (await server.stores.user.getById(user.id))!;
let html = '';
const res = { send: (out: string) => (html = out) };
await server.services.homepage.send(
{
req: { headers: {}, query: {}, get: () => undefined },
res,
actor: { user: row },
} as never,
{ title: 'Puter' } as never,
{} as never,
);
const params = JSON.parse(
/gui\((\{.*?\})\);/s.exec(html)![1].replaceAll('\\u003c', '<'),
);
expect(params.teams_ui).toBe(false);
});
});
@@ -108,25 +108,6 @@ describe('AbstractDatabaseClient — unimplemented surface', () => {
});
describe('AbstractDatabaseClient — replica-aware reads', () => {
it('avoids a primary read when the replica has rows in sequential mode', async () => {
const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]);
expect(
await client.tryHardRead('SELECT 1', [], { race: false }),
).toEqual([{ id: 1 }]);
expect(client.preads).toEqual([]);
});
it.each([[], new Error('replica unavailable')])(
'falls back once in sequential mode',
async (replica) => {
const client = new FixtureDatabaseClient(replica, [{ id: 2 }]);
expect(
await client.tryHardRead('SELECT 1', [], { race: false }),
).toEqual([{ id: 2 }]);
expect(client.preads).toEqual(['SELECT 1']);
},
);
it('returns replica rows without waiting on the primary', async () => {
const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]);
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
@@ -275,28 +256,6 @@ describe('AbstractDatabaseClient — SQL generation', () => {
expect(postgres().timestampFromUnixSql()).toBe('to_timestamp(?)');
});
it('reads a timestamp column back as unix seconds in each engine', () => {
expect(sqlite().unixSecondsSql('`t`')).toBe(
"CAST(strftime('%s', `t`) AS INTEGER)",
);
expect(mysql().unixSecondsSql('`t`')).toBe('UNIX_TIMESTAMP(`t`)');
expect(postgres().unixSecondsSql('`t`')).toBe(
'EXTRACT(EPOCH FROM `t`)::bigint',
);
});
it('reads the database clock as epoch milliseconds in each dialect', () => {
expect(sqlite().nowMsExpr()).toBe(
"CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)",
);
expect(mysql().nowMsExpr()).toBe(
"(TIMESTAMPDIFF(MICROSECOND, '1970-01-01 00:00:00', UTC_TIMESTAMP(6)) DIV 1000)",
);
expect(postgres().nowMsExpr()).toBe(
'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)',
);
});
it('describes a stored timestamp the way each driver reads it back', () => {
const seconds = Date.UTC(2026, 9, 11, 8, 5, 9) / 1000;
expect(sqlite().timestampValue(seconds)).toBe('2026-10-11 08:05:09');
+5 -35
View File
@@ -138,27 +138,25 @@ export class AbstractDatabaseClient extends PuterClient {
/**
* Like `read()`, but an empty or failed replica read falls back to the
* primary, so replication lag can't hide a row that was just written. By
* default the primary read starts alongside the replica one; `race: false`
* reads the primary only after the replica returns empty or fails. Without
* a replica this is a single `read()`.
* primary, so replication lag can't hide a row that was just written. The
* primary read starts alongside the replica one rather than after it.
* Without a replica this is a single `read()`.
*/
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
async tryHardRead(
query: string,
params: unknown[] = [],
{ race = true }: { race?: boolean } = {},
): Promise<Record<string, unknown>[]> {
if (!this.hasReadReplica()) return this.read(query, params);
const primary = race ? settle(this.pread(query, params)) : null;
const primary = settle(this.pread(query, params));
try {
const rows = await this.replicaRead(query, params);
if (rows.length > 0) return rows;
} catch {
// replica failed — fall through to primary
}
const result = await (primary ?? settle(this.pread(query, params)));
const result = await primary;
if ('error' in result) throw result.error;
return result.value;
}
@@ -388,34 +386,6 @@ export class AbstractDatabaseClient extends PuterClient {
});
}
/**
* A timestamp column as unix seconds, computed by the database: the mysql
* driver reads a stored UTC timestamp as local time, so parsing it in JS
* can be off by the server's UTC offset.
*/
unixSecondsSql(column: string): string {
return this.case({
postgres: `EXTRACT(EPOCH FROM ${column})::bigint`,
mysql: `UNIX_TIMESTAMP(${column})`,
otherwise: `CAST(strftime('%s', ${column}) AS INTEGER)`,
});
}
/**
* The database's own clock as epoch milliseconds, so nodes whose clocks
* disagree still agree on it. The mysql form ignores the session time zone,
* so a DST change can't shift it.
*/
nowMsExpr(): string {
return this.case({
sqlite: "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)",
postgres:
'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)',
otherwise:
"(TIMESTAMPDIFF(MICROSECOND, '1970-01-01 00:00:00', UTC_TIMESTAMP(6)) DIV 1000)",
});
}
/**
* What a read of a timestamp column holding `seconds` returns: sqlite gives
* back the UTC text it stored, mysql2 and pg a `Date` read in node's time
@@ -217,8 +217,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
return this.configuration === Configuration.REPLICA;
}
// No failover: `tryHardRead` falls back to the primary on an empty result
// or error (raced alongside by default, after the replica with `race: false`).
// No failover: `tryHardRead` already has a primary read in flight.
protected override async replicaRead(
query: string,
params: unknown[],
@@ -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 = 96;
const CURRENT_SCHEMA_VERSION = 90;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -673,38 +673,6 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
});
});
it('adds kv_share_handles.notify from 0099, off for every existing share', async () => {
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-notify-'));
const path = join(dir, 'puter.sqlite');
try {
const before = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path, targetVersion: 94 }),
);
await before.onServerStart();
await before.write(
'INSERT INTO `kv_share_handles` (`handle`, `owner_user_id`, ' +
'`grantee_user_id`, `app_uid`, `key_prefix`, `permission`, ' +
'`created_at`) VALUES (?, 1, 1, ?, ?, ?, 0)',
['kvh-before-notify', 'os-global', 'a:', 'kv-share:x'],
);
before.onServerShutdown();
const after = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path }),
);
await after.onServerStart();
await expect(
after.read(
'SELECT `notify` FROM `kv_share_handles` WHERE `handle` = ?',
['kvh-before-notify'],
),
).resolves.toEqual([{ notify: 0 }]);
after.onServerShutdown();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('indexes notification.created_at for the retention sweep', async () => {
const rows = await client.read('PRAGMA index_list(`notification`)');
expect(rows.map((r) => String(r.name))).toContain(
@@ -712,29 +680,6 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
);
});
it('indexes notification.replaced_at per mailbox slice for catch-up', async () => {
const columns = await client.read(
'PRAGMA index_info(`idx_notification_replaced`)',
);
expect(columns.map((c) => String(c.name))).toEqual([
'user_id',
'audience',
'app_uid',
'replaced_at',
]);
});
it('keeps notification.send_key unique', async () => {
const rows = await client.read('PRAGMA index_list(`notification`)');
expect(
rows.find((r) => r.name === 'idx_notification_send_key'),
).toMatchObject({ unique: 1 });
const columns = await client.read(
'PRAGMA index_info(`idx_notification_send_key`)',
);
expect(columns.map((c) => String(c.name))).toEqual(['send_key']);
});
it('leaves an already-migrated database untouched on a second boot', async () => {
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-'));
const path = join(dir, 'nested', 'puter.sqlite');
@@ -129,12 +129,6 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[87, ['0092_share-entry-owner.sql']],
[88, ['0093_fsentries-parent-uid-index.sql']],
[89, ['0094_deleted-apps.sql']],
[90, ['0095_kv-share-handle-levels.sql']],
[91, ['0096_kv-share-handle-quota-indexes.sql']],
[92, ['0097_user-to-user-holder-permission-index.sql']],
[93, ['0098_notification-groups.sql']],
[94, ['0099_kv-share-handle-notify.sql']],
[95, ['0100_notification-send-key.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -1,50 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Share levels on `kv_share_handles`. See
-- sqlite/0095_kv-share-handle-levels.sql for the rationale. No per-file
-- applied-state tracking, so every step is guarded and a replay does nothing.
CALL _puter_add_col('kv_share_handles', 'revoked_by', '`revoked_by` varchar(8) DEFAULT NULL');
CALL _puter_add_col('kv_share_handles', 'label', '`label` varchar(512) DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_indexes;
DELIMITER //
CREATE PROCEDURE _puter_add_kv_share_handle_indexes()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_grantee'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_grantee` (`grantee_user_id`, `id`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_app'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_app` (`app_uid`);
END IF;
END//
DELIMITER ;
CALL _puter_add_kv_share_handle_indexes();
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_indexes;
@@ -1,72 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Share-handle quota, listing and purge indexes. See
-- sqlite/0096_kv-share-handle-quota-indexes.sql for what each serves. No
-- per-file applied-state tracking, so every index is guarded and a replay does
-- nothing.
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_quota_indexes;
DELIMITER //
CREATE PROCEDURE _puter_add_kv_share_handle_quota_indexes()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_owner_live'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_owner_live` (`owner_user_id`, `revoked_at`, `app_uid`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_owner_app'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_owner_app` (`owner_user_id`, `app_uid`, `id`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_grantee_app'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_grantee_app` (`grantee_user_id`, `app_uid`, `id`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_pair'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_pair` (`owner_user_id`, `grantee_user_id`, `revoked_at`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_revoked'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_revoked` (`revoked_at`);
END IF;
END//
DELIMITER ;
CALL _puter_add_kv_share_handle_quota_indexes();
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_quota_indexes;
@@ -1,38 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Lets a holder's grant check read only the permissions it names. Guarded, so
-- a replay does nothing.
DROP PROCEDURE IF EXISTS _puter_add_u2u_holder_permission_index;
DELIMITER //
CREATE PROCEDURE _puter_add_u2u_holder_permission_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_to_user_permissions'
AND INDEX_NAME = 'idx_user_to_user_permissions_holder_permission'
) THEN
ALTER TABLE `user_to_user_permissions` ADD INDEX `idx_user_to_user_permissions_holder_permission` (`holder_user_id`, `permission`);
END IF;
END//
DELIMITER ;
CALL _puter_add_u2u_holder_permission_index();
DROP PROCEDURE IF EXISTS _puter_add_u2u_holder_permission_index;
@@ -1,53 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- `notification`.`group_key` and `replaced_at`. See
-- sqlite/0098_notification-groups.sql for the rationale. The key is a hex
-- digest, so ascii and compared byte for byte. No per-file applied-state
-- tracking, so every step is guarded and a replay does nothing.
CALL _puter_add_col('notification', 'group_key', '`group_key` char(64) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL');
CALL _puter_add_col('notification', 'replaced_at', '`replaced_at` bigint DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_notification_group_indexes;
DELIMITER //
CREATE PROCEDURE _puter_add_notification_group_indexes()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'notification'
AND INDEX_NAME = 'idx_notification_group'
) THEN
ALTER TABLE `notification` ADD INDEX `idx_notification_group`
(`user_id`, `app_uid`, `group_key`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'notification'
AND INDEX_NAME = 'idx_notification_replaced'
) THEN
ALTER TABLE `notification` ADD INDEX `idx_notification_replaced`
(`user_id`, `audience`, `app_uid`, `replaced_at`);
END IF;
END//
DELIMITER ;
CALL _puter_add_notification_group_indexes();
DROP PROCEDURE IF EXISTS _puter_add_notification_group_indexes;
@@ -1,23 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Notifications through a share handle. See
-- sqlite/0099_kv-share-handle-notify.sql for the column. No per-file
-- applied-state tracking, so it goes through _puter_add_col and a replay does
-- nothing.
CALL _puter_add_col('kv_share_handles', 'notify', '`notify` tinyint(1) NOT NULL DEFAULT 0');
@@ -1,43 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- `notification`.`send_key`. See sqlite/0100_notification-send-key.sql for the
-- rationale. The key is a uuid, so ascii and compared byte for byte. No
-- per-file applied-state tracking, so every step is guarded and a replay does
-- nothing.
CALL _puter_add_col('notification', 'send_key', '`send_key` char(36) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_notification_send_key_index;
DELIMITER //
CREATE PROCEDURE _puter_add_notification_send_key_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'notification'
AND INDEX_NAME = 'idx_notification_send_key'
) THEN
ALTER TABLE `notification` ADD UNIQUE KEY `idx_notification_send_key`
(`send_key`), ALGORITHM=INPLACE, LOCK=NONE;
END IF;
END//
DELIMITER ;
CALL _puter_add_notification_send_key_index();
DROP PROCEDURE IF EXISTS _puter_add_notification_send_key_index;
@@ -1,29 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Share levels on kv_share_handles. See
-- sqlite/0095_kv-share-handle-levels.sql for the rationale. Idempotent via
-- IF NOT EXISTS; there is no per-file applied-state tracking.
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS revoked_by VARCHAR(8) DEFAULT NULL;
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS label VARCHAR(512) DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_grantee
ON kv_share_handles (grantee_user_id, id);
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_app
ON kv_share_handles (app_uid);
@@ -1,35 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Share-handle quota, listing and purge indexes. See
-- sqlite/0096_kv-share-handle-quota-indexes.sql for what each serves.
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_owner_live
ON kv_share_handles (owner_user_id, revoked_at, app_uid);
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_owner_app
ON kv_share_handles (owner_user_id, app_uid, id);
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_grantee_app
ON kv_share_handles (grantee_user_id, app_uid, id);
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_pair
ON kv_share_handles (owner_user_id, grantee_user_id, revoked_at);
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_revoked
ON kv_share_handles (revoked_at);
@@ -1,21 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Lets a holder's grant check read only the permissions it names.
CREATE INDEX IF NOT EXISTS idx_user_to_user_permissions_holder_permission
ON user_to_user_permissions (holder_user_id, permission);
@@ -1,29 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- notification.group_key and replaced_at. See
-- sqlite/0098_notification-groups.sql for the rationale. Idempotent via IF NOT
-- EXISTS.
ALTER TABLE notification ADD COLUMN IF NOT EXISTS group_key varchar(64) DEFAULT NULL;
ALTER TABLE notification ADD COLUMN IF NOT EXISTS replaced_at bigint DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_notification_group
ON notification (user_id, app_uid, group_key);
CREATE INDEX IF NOT EXISTS idx_notification_replaced
ON notification (user_id, audience, app_uid, replaced_at);
@@ -1,22 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Notifications through a share handle. See
-- sqlite/0099_kv-share-handle-notify.sql for the column. Idempotent via
-- IF NOT EXISTS; there is no per-file applied-state tracking.
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS notify smallint NOT NULL DEFAULT 0;
@@ -1,24 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- notification.send_key. See sqlite/0100_notification-send-key.sql for the
-- rationale. Idempotent via IF NOT EXISTS.
ALTER TABLE notification ADD COLUMN IF NOT EXISTS send_key char(36) DEFAULT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_send_key
ON notification (send_key);
@@ -1,35 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- What share levels need from `kv_share_handles`. The level itself is not a
-- column: it is the root of the stored `permission`.
--
-- - `revoked_by` : `owner`, `holder` or `system` (the namespace app was
-- deleted). Null while live, and on rows retired before
-- this existed.
-- - `label` : optional owner-set text the holder is shown.
-- - the grantee index serves the holder's listing and their unshare, and
-- the app index retiring a deleted app's handles.
ALTER TABLE `kv_share_handles` ADD COLUMN `revoked_by` TEXT DEFAULT NULL;
ALTER TABLE `kv_share_handles` ADD COLUMN `label` TEXT DEFAULT NULL;
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_grantee`
ON `kv_share_handles` (`grantee_user_id`, `id`);
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_app`
ON `kv_share_handles` (`app_uid`);
@@ -1,42 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Indexes behind the share-handle quotas, the app-filtered listings and the
-- retention purge on `kv_share_handles`.
--
-- - owner_live : the owner's live count, in total and per app, read off
-- the index alone.
-- - owner_app : the owner's listing narrowed to one app, in id order.
-- - grantee_app : the holder's listing narrowed to one app, in id order.
-- - pair : live handles one owner holds out to one holder, counted at
-- mint and walked on revoke and unshare.
-- - revoked : retired rows past retention, for the purge.
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_owner_live`
ON `kv_share_handles` (`owner_user_id`, `revoked_at`, `app_uid`);
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_owner_app`
ON `kv_share_handles` (`owner_user_id`, `app_uid`, `id`);
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_grantee_app`
ON `kv_share_handles` (`grantee_user_id`, `app_uid`, `id`);
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_pair`
ON `kv_share_handles` (`owner_user_id`, `grantee_user_id`, `revoked_at`);
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_revoked`
ON `kv_share_handles` (`revoked_at`);
@@ -1,21 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Lets a holder's grant check read only the permissions it names.
CREATE INDEX IF NOT EXISTS `idx_user_to_user_permissions_holder_permission`
ON `user_to_user_permissions` (`holder_user_id`, `permission`);
@@ -1,38 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- What app message groups need from `notification`:
--
-- - `group_key` : a sha256 hex digest of an app message's `group`, so the
-- notification a newer one replaces is found by index.
-- Null on every other row, and on rows written before
-- this, which therefore never match.
-- - `replaced_at` : epoch milliseconds of the last replacement, null for a
-- row never replaced. Live and fetched copies carry it as
-- their version, so a client can tell a replacement from a
-- copy it already has. Indexed per mailbox slice, so a
-- catch-up reads the rows replaced since its cursor
-- without scanning the ones that never were.
ALTER TABLE `notification` ADD COLUMN `group_key` TEXT DEFAULT NULL;
ALTER TABLE `notification` ADD COLUMN `replaced_at` INTEGER DEFAULT NULL;
CREATE INDEX IF NOT EXISTS `idx_notification_group`
ON `notification` (`user_id`, `app_uid`, `group_key`);
CREATE INDEX IF NOT EXISTS `idx_notification_replaced`
ON `notification` (`user_id`, `audience`, `app_uid`, `replaced_at`);
@@ -1,23 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Whether the two parties of a share may send each other notifications through
-- its handle. Off by default, so every existing share reads as not allowing
-- them.
ALTER TABLE `kv_share_handles`
ADD COLUMN `notify` INTEGER NOT NULL DEFAULT 0;
@@ -1,27 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- `notification`.`send_key`: the key of the delayed send (a `deferred`
-- schedule) that wrote this row or last replaced it, derived from the
-- schedule and its slot. Unique, so one slot can never write two rows, and a
-- send that crashed after writing finds its row again by it. Null on every row
-- a delayed send never touched.
ALTER TABLE `notification` ADD COLUMN `send_key` TEXT DEFAULT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS `idx_notification_send_key`
ON `notification` (`send_key`);
@@ -188,47 +188,6 @@ describe('DDBClient — item operations', () => {
).toBeUndefined();
});
it('puts only while its condition holds', async () => {
const absent = {
expression: 'attribute_not_exists(#pk)',
names: { '#pk': 'pk' },
};
await client.put(
TABLE,
{ pk: 'u7', sk: 'once', v: 1 },
{
condition: absent,
},
);
await expect(
client.put(
TABLE,
{ pk: 'u7', sk: 'once', v: 2 },
{
condition: absent,
},
),
).rejects.toMatchObject({ name: 'ConditionalCheckFailedException' });
expect(
(await client.get(TABLE, { pk: 'u7', sk: 'once' })).Item?.v,
).toBe(1);
await client.put(
TABLE,
{ pk: 'u7', sk: 'once', v: 3 },
{
condition: {
expression: '#v = :held',
names: { '#v': 'v' },
values: { ':held': 1 },
},
},
);
expect(
(await client.get(TABLE, { pk: 'u7', sk: 'once' })).Item?.v,
).toBe(3);
});
it('fetches keys from several tables in one batch', async () => {
await client.put(TABLE, { pk: 'b1', sk: 'a', v: 1 });
await client.put(TABLE, { pk: 'b1', sk: 'b', v: 2 });
@@ -542,19 +501,6 @@ describe('DDBClient — credentialled configuration', () => {
).toThrow('requires both `access_key` and `secret_key`');
});
it('accepts the credential chain in place of keys', async () => {
const client = new DDBClient({
port: 0,
extensions: [],
dynamo: {
aws: { useCredentialChain: true, region: 'us-west-2' },
endpoint: 'http://127.0.0.1:1',
},
} as unknown as IConfig);
await expect(client.recreateClient()).resolves.toBeUndefined();
});
it('skips table creation on a managed deployment', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const client = new DDBClient({
+8 -48
View File
@@ -43,10 +43,6 @@ import { once } from 'node:events';
import { Agent as httpsAgent } from 'node:https';
import { HttpError } from '../../core/http';
import type { IConfig, IDynamoConfig } from '../../types';
import {
awsCredentialsFrom,
type AwsCredentials,
} from '../../util/awsCredentials.js';
import { BoundedTtlMap } from '../../util/boundedTtlMap.js';
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
import { Span } from '../../util/span.js';
@@ -252,19 +248,7 @@ export class DDBClient extends PuterClient {
}
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
async put<T extends Record<string, unknown>>(
table: string,
item: T,
opts?: {
/** Refused (`ConditionalCheckFailedException`) unless it holds. */
condition?: {
expression: string;
names?: Record<string, string>;
values?: Record<string, unknown>;
};
},
) {
const condition = opts?.condition;
async put<T extends Record<string, unknown>>(table: string, item: T) {
const client = await this.#getDocumentClient();
return sendRepairingValues(
item,
@@ -274,23 +258,6 @@ export class DDBClient extends PuterClient {
TableName: table,
Item: itemToWrite,
ReturnConsumedCapacity: 'TOTAL',
...(condition
? {
ConditionExpression: condition.expression,
...(condition.names
? {
ExpressionAttributeNames:
condition.names,
}
: {}),
...(condition.values
? {
ExpressionAttributeValues:
condition.values,
}
: {}),
}
: {}),
}),
),
() => `a put to ${table}`,
@@ -534,8 +501,6 @@ export class DDBClient extends PuterClient {
scanIndexForward?: boolean;
beginsWith?: { key: string; value: string };
select?: 'COUNT';
/** Attributes to return instead of the whole item. */
projection?: string[];
filter?: {
expression: string;
values?: Record<string, unknown>;
@@ -576,10 +541,6 @@ export class DDBClient extends PuterClient {
Object.assign(expressionAttributeValues, options.filter.values);
Object.assign(expressionAttributeNames, options.filter.names);
}
const projection = (options?.projection ?? []).map((name) => {
expressionAttributeNames[`#p_${name}`] = name;
return `#p_${name}`;
});
const command = new QueryCommand({
TableName: table,
@@ -594,9 +555,6 @@ export class DDBClient extends PuterClient {
? { FilterExpression: options.filter.expression }
: {}),
...(options?.select ? { Select: options.select } : {}),
...(projection.length > 0
? { ProjectionExpression: projection.join(', ') }
: {}),
...(options?.scanIndexForward !== undefined
? { ScanIndexForward: options.scanIndexForward }
: {}),
@@ -730,15 +688,17 @@ export class DDBClient extends PuterClient {
}
#bindAwsClient() {
const credentials = awsCredentialsFrom(this.#ddbConfig.aws);
if (!credentials) {
const accessKeyId = this.#ddbConfig.aws?.access_key;
const secretAccessKey = this.#ddbConfig.aws?.secret_key;
if (!accessKeyId || !secretAccessKey) {
throw new Error(
'DynamoDB aws config requires both `access_key` and `secret_key`, or `useCredentialChain`',
'DynamoDB aws config requires both `access_key` and `secret_key`',
);
}
this.#bindClient({
credentials,
credentials: { accessKeyId, secretAccessKey },
region: this.#ddbConfig.aws?.region || 'us-west-2',
endpoint: this.#ddbConfig.endpoint,
});
@@ -760,7 +720,7 @@ export class DDBClient extends PuterClient {
region,
endpoint,
}: {
credentials: AwsCredentials;
credentials: { accessKeyId: string; secretAccessKey: string };
region: string;
endpoint?: string;
}) {
+5 -133
View File
@@ -75,15 +75,6 @@ export type EventMap = {
serverStart: Record<string, never>;
serverPrepareShutdown: Record<string, never>;
serverShutdown: Record<string, never>;
/**
* Emitted by a host extension that knows this server will restart soon.
* Long-lived connections ask their clients to reconnect; nothing is refused
* or closed, so a false alarm is harmless.
*/
serverRestartAnnounced: {
/** How long until the server stops, when known. */
closeInMs?: number;
};
// ---- AI ----
'ai.prompt.validate': {
@@ -146,17 +137,6 @@ export type EventMap = {
};
'app.new-icon': { app_uid: string; data_url: string };
'app.opened': { app_uid: string; user_id: number; ts: number };
/**
* A user removed an app: its grants and worker sessions are gone. With
* `deleteData`, the user asked for the app's data in their account to go
* too. Awaited by the emitter.
*/
'app.uninstalled': {
userId: number;
userUuid: string;
appUid: string;
deleteData: boolean;
};
'app.rename': {
app_uid: string;
old_name: string;
@@ -217,7 +197,7 @@ export type EventMap = {
*/
is_temp?: boolean;
ip?: string | null;
source?: 'oidc' | 'team_provision';
source?: 'oidc';
req?: unknown;
data?: unknown;
abuse?: unknown;
@@ -277,7 +257,7 @@ export type EventMap = {
ip?: string | null;
fingerprint?: string | null;
reputation?: number | null;
source?: 'oidc' | 'team_provision';
source?: 'oidc';
requires_phone_verification: boolean;
requires_card_verification: boolean;
accepted: boolean;
@@ -323,8 +303,6 @@ export type EventMap = {
// the abuse extension cap sends per device (across accounts) so a
// device farm can't dodge the per-account cap with fresh signups.
device_fingerprint: string | null;
// Browser-signals dispatch id the client sent, or null.
dispatch_id: string | null;
allowed: boolean;
reason: string | null;
// With `allowed` false, offer the card fallback in place of SMS. Only
@@ -411,9 +389,6 @@ export type EventMap = {
new_username: string;
};
'user.email-changed': { user_id: number; new_email: string };
/** An account was suspended, or its suspension lifted. Local events. */
'user.suspended': { userId: number };
'user.unsuspended': { userId: number };
// Fired after an account is torn down (self-serve, admin, or temp-user
// logout cleanup). Listeners purge external state tied to the account —
// e.g. the marketplace extension cancels the user's Stripe subscriptions.
@@ -422,8 +397,6 @@ export type EventMap = {
user_id: number;
user_uuid?: string;
stripe_customer_id?: string | null;
/** Apps the account owned, which cascaded away with it. */
appUids?: string[];
};
// ---- Team billing ---- the trigger, never the charge ----
@@ -517,33 +490,13 @@ export type EventMap = {
noShareKeys?: string[];
/** Events handler runs behind the write; see `Actor.handlerDepth`. */
handlerDepth?: number;
/**
* The user who made the change. Differs from `userId` when a share
* holder wrote into the owner's namespace.
*/
actingUserId?: number;
/** Made by a worker session or an events handler; see `viaWorkerOf`. */
viaWorker?: true;
};
/**
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
* key enumeration is truncated for a large namespace, so the keys it saw
* are not the keys it removed.
*/
'kv.flushed': {
namespace: string;
userId: number;
actingUserId?: number;
viaWorker?: true;
};
/**
* Share handles stopped being addresses, whoever retired them. `userId` is
* one user who could have subscribed through them, their owner or their
* holder. Listeners end what was made through them even where another grant
* still covers the region. Local, like `permission.revoked`: one region
* settling covers every other.
*/
'kv.share.retired': { handles: string[]; userId: number };
'kv.flushed': { namespace: string; userId: number };
// ---- Notifications ----
/**
@@ -560,30 +513,9 @@ export type EventMap = {
appUid: string | null;
value: Record<string, unknown>;
createdAt: number;
/** An earlier notification replaced in place; `createdAt` is when. */
replaced?: true;
/** Events handler runs behind whatever created it. */
handlerDepth?: number;
};
/**
* Every non-silent write or update, only on the node that wrote it. The
* value is the listener's own copy of what the socket carries.
*/
'notif.written': {
userId: number;
uid: string;
type: string;
audience: string;
/** The row's app; null when it names none or an update could not tell. */
appUid: string | null;
value: Record<string, unknown>;
/** An earlier notification rewritten in place. */
updated?: true;
/** Names this one send, for a receipt asked about it. */
sendId: string;
/** The same `sendId` may be emitted again, as a delayed send is. */
replayable?: true;
};
/**
* One notification, addressed at whatever sockets each region holds for the
* recipient. `outer.*` reaches peer regions and never sibling nodes, so
@@ -592,31 +524,9 @@ export type EventMap = {
*/
'outer.notif.delivery': {
userId: number;
wire: 'notif.message' | 'notif.unreads' | 'notif.ack' | 'notif.removed';
wire: 'notif.message' | 'notif.unreads' | 'notif.ack';
response: unknown;
};
/**
* How one live notification went, on the node that asked for its receipt:
* from the desktop's sockets, or with `appUid` the app's own.
*/
'notif.delivery.settled': {
sendId?: string;
uid: string;
userId: number;
type: string;
/** The app whose own sockets were asked; absent for the desktop's. */
appUid?: string;
outcome: NotifDeliveryOutcome;
/** A socket in this region acknowledges the receipt. */
ackCapable: boolean;
/** No socket here is an older client that acknowledges nothing. */
everySocketAcks: boolean;
/** Every node here answered the ask, not just this one. */
complete: boolean;
/** A peer region holds a socket for the user. */
peerRegions: boolean;
responders: NotifDeliveryResponder[];
};
// ---- Metering ----
// Recurring charges are pure mechanism here: the metering service knows
@@ -733,13 +643,6 @@ export type EventMap = {
* reason as the events generation bump: a per-process cache.
*/
'outer.pubsub.share.ownerShared': { ownerUserId: number };
/**
* Share handles were retired or changed, so every process must drop its
* cached copy. `outer.pubsub.*` because the cache is a per-process map.
*/
'outer.pubsub.kv.share.forget': { handles: string[] };
/** The same, for every cached handle one owner holds out. */
'outer.pubsub.kv.share.forgetOwner': { ownerUserId: number };
'outer.fs.write-hash': { hash: string; uuid: string };
/**
* Cache keys the KV read cache must stop serving, because the entries
@@ -758,7 +661,6 @@ export type EventMap = {
'outer.gui.item.renamed': GuiEvent;
'outer.gui.notif.ack': GuiEvent<{ uid: string }>;
'outer.gui.notif.message': GuiEvent<{ uid: string; notification: unknown }>;
'outer.gui.notif.removed': GuiEvent<{ uid: string }>;
'outer.gui.notif.unreads': GuiEvent<{
unreads: { uid: string; notification: unknown }[];
}>;
@@ -766,7 +668,7 @@ export type EventMap = {
// ---- Subdomains ----
// `uid` is the row uuid — for `delete` it is the only surviving handle a
// listener can use to find state keyed to the row after it is gone.
'subdomain.delete': { subdomain: string; uid?: string; userId: number };
'subdomain.delete': { subdomain: string; uid?: string };
'subdomain.update': { subdomain: string; uid?: string };
'site.htmlServed': {
subdomain: string;
@@ -777,10 +679,6 @@ export type EventMap = {
requestHash?: string;
mime: string;
};
// Asked for a request whose host is outside every configured domain. A
// listener that recognises the host sets `result.subdomain` to the site
// it serves; an unclaimed host is answered with a 404.
'site.resolveHost': { host: string; result: { subdomain: string | null } };
// Asked before a hosted file is streamed. A listener that owns the site
// sets `result.allowed = false` to withhold the entry; the visitor then
// sees the same 404 as for a missing file.
@@ -954,9 +852,6 @@ export type AiCostFactorEvent = {
*/
export type LifecyclePhase = 'before' | 'after' | 'error' | 'reject';
/** Status a vetoing `before` listener may answer with. */
export type LifecycleRejectStatus = 403 | 429;
/**
* Payload for `driver.<iface>.<method>.<phase>` events.
*
@@ -986,8 +881,6 @@ export type DriverMethodLifecycleEvent = {
allow?: boolean;
/** Optional human-readable reason surfaced to the caller when vetoed. */
rejectReason?: string;
/** Status for a veto: 403 (default) or 429 for a throttle. */
rejectStatus?: LifecycleRejectStatus;
};
/**
@@ -1022,8 +915,6 @@ export type RouteLifecycleEvent = {
allow?: boolean;
/** Optional human-readable reason surfaced to the caller when vetoed. */
rejectReason?: string;
/** Status for a veto: 403 (default) or 429 for a throttle. */
rejectStatus?: LifecycleRejectStatus;
};
export type EventKey = keyof EventMap & string;
@@ -1045,25 +936,6 @@ export type MatchingEvents<P extends ListenKey> = P extends `${infer Prefix}.*`
? Extract<EventKey, `${Prefix}.${string}`>
: P & EventKey;
/** Acked, not acked, no socket anywhere, or sockets only in a peer region. */
export type NotifDeliveryOutcome =
| 'received'
| 'unreceived'
| 'no-socket'
| 'remote-only';
/** One socket's receipt for a live notification. */
export interface NotifDeliveryResponder {
visibilityState: 'visible' | 'hidden' | 'unknown';
/** The page's window has focus. Absent from older clients. */
focused?: boolean;
/**
* The page shows its own browser notification while hidden or unfocused.
* Absent from older clients.
*/
showsOwn?: boolean;
}
export type EventMetadata = {
from_outside?: boolean;
/** Set by `emitAndWait` when a listener threw, so a verdict hook can refuse. */
File diff suppressed because it is too large. Load diff
@@ -43,20 +43,6 @@ import { PuterClient } from '../types.js';
*
* On a miss, a handler set here may deploy the script itself and retry once
* with `x-puter-events-deployed: 1`, skipping the dispatcher's own callback.
*
* Calls that come due within a few milliseconds of each other go out together:
*
* POST <events dispatcher>/invoke-batch
* x-puter-internal-auth: <internal secret>
* { calls: [{ script, appUid, key, deployed?, body, waitMs }] }
*
* The dispatcher runs a few at a time, in order, and streams NDJSON: `{ i,
* started: true }` when a call goes to its worker, then `{ i, status, handled?,
* error?, retryAfter?, dispatch? }` when it finishes — the same facts the
* single route carries in its status and headers. A call's timeout runs from
* its `started` line, so time spent queued behind other calls is never the
* handler's. A call still queued `waitMs` after the batch arrived is never
* started and answers `dispatch: "queue-timeout"`.
*/
/** The one route an events worker answers. */
@@ -65,27 +51,6 @@ export const EVENTS_INVOKE_PATH = '/__events/invoke';
/** The route the events dispatcher takes an invocation on. */
export const EVENTS_DISPATCH_PATH = '/invoke';
/** The route it takes several on, answering each as it finishes. */
export const EVENTS_DISPATCH_BATCH_PATH = '/invoke-batch';
/** Calls one batch carries at most. */
export const INVOKE_BATCH_SIZE = 60;
/** How long the first call of a batch waits for others to join it. */
export const INVOKE_BATCH_WINDOW_MS = 5;
/**
* How long a dispatcher without the batch route is sent single calls before the
* batch route is tried again.
*/
const BATCH_UNSUPPORTED_RETRY_MS = 5 * 60_000;
/**
* How long past its wait a batched call's `started` line may take to arrive
* before the call is given up as never started.
*/
const BATCH_START_GRACE_MS = 2_000;
/**
* Header the dispatcher marks its own failures with. Anything carrying it never
* reached a handler, so its status says nothing about the delivery.
@@ -103,23 +68,6 @@ export const EVENTS_HANDLED_HEADER = 'x-puter-events-handled';
/** Machine-readable reason on the runtime's own failure answers. */
export const EVENTS_ERROR_HEADER = 'x-puter-events-error';
/**
* The runtime's answer to a key it does not hold: the script was deployed under
* a secret that has since rotated, and redeploying it binds the current key.
*/
const BAD_KEY_ERROR = 'bad-key';
/**
* Runtime failure answers that are Puter's fault rather than the handler's: a
* key the script no longer matches, no token to run as, or a handler that
* failed because a Puter API call it made did.
*/
const PLATFORM_ERROR_CODES = new Set([
BAD_KEY_ERROR,
'no-token',
'platform-error',
]);
/**
* Set on the retry after this backend deploys a missing script itself, so the
* dispatcher skips its own deploy-on-miss callback and negative cache and goes
@@ -147,16 +95,10 @@ const SELF_DEPLOYABLE_MISS_REASONS = new Set([
*/
const ALREADY_DEPLOYED_MISS_REASONS = new Set(['deploy-timeout']);
/** Whose call asked for the deploy, for the share of deploys it may take. */
export interface WorkerMissOptions {
lane?: InvokeLane;
}
/** What a self-deploy attempt did, and to which app/script it applies. */
export type WorkerMissHandler = (
appUid: string,
script: string,
options: WorkerMissOptions,
) => Promise<'deployed' | string>;
/** How long a handler has to answer before the attempt is abandoned. */
@@ -164,31 +106,12 @@ export const EVENTS_INVOKE_TIMEOUT_MS = 30_000;
/**
* Connections held open to the dispatcher, so a busy fleet is not
* re-handshaking. Also how many calls one transport makes at once: a call past
* that waits for a free one before its invoke timeout starts.
* re-handshaking. The 30s invoke abort starts when a request is queued, not
* when it gets a connection, so a saturated pool shows up as timeouts.
*/
export const INVOKE_CONNECTIONS = 256;
/**
* Of those, how many schedule runs may hold at once, so a burst of schedules
* can never take the connections event deliveries need.
*/
export const SCHEDULE_INVOKE_CONNECTIONS = 64;
/** Whose call this is, for the connection share it may take. */
export type InvokeLane = 'delivery' | 'schedule';
/** What a deploy answers when another deploy of the region holds the way. */
export const DEPLOY_BUSY = 'busy';
const INVOKE_CONNECTIONS = 256;
const INVOKE_KEEP_ALIVE_MS = 30_000;
/**
* A call waits at most this share of its invoke timeout for a connection, so
* the wait and the run together stay inside the worker lease (twice the
* timeout). Past it the call fails as Puter's, not the handler's.
*/
const CONNECTION_WAIT_SHARE = 0.5;
/** One invocation, as the events service hands it over. */
export interface WorkerInvokeRequest {
/** Script the app's current handler set deploys as. */
@@ -214,69 +137,36 @@ export interface EventsInvokeCall {
timeoutMs: number;
/** Set on the retry after this backend deployed the script itself. */
deployed?: boolean;
lane?: InvokeLane;
/**
* Builds `body` again at send time, for a transport that waits for a
* connection first, so a send time stamped into it is the real one.
*/
render?: () => string;
}
/** What one attempt at delivering an invocation came back with. */
export interface EventsInvokeAnswer {
/** `null` when no answer came back. */
status: number | null;
/** Set only when the answer provably came from the script. */
handled?: boolean;
error?: string;
/** Set only by the dispatcher transport, naming its own failure. */
dispatchReason?: string;
/** No answer within the invoke timeout. */
timedOut?: boolean;
/** From a `retry-after` header in seconds. */
retryAfterMs?: number;
/** A deploy this call needed was put off: another held the way. */
deployBusy?: boolean;
}
/**
* Delivers an invocation. `handled` is set only when the answer provably came
* from the script, so an unmarked 4xx is 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<EventsInvokeAnswer>;
send(call: EventsInvokeCall): Promise<{
status: number | null;
handled?: boolean;
error?: string;
/** Set only by the dispatcher transport, naming its own failure. */
dispatchReason?: string;
}>;
}
/**
* `settled`: handler accepted (2xx). `terminal`: handler refused (marked 4xx),
* not retried. `retriable`: the handler failed or ran past the timeout.
* `unavailable`: Puter could not start or finish the run (transport, dispatch
* and deploy failures, unmarked answers, a Puter API failure inside the
* handler); retried like `retriable` but never the handler's fault.
* not retried. `retriable`: no handler answer (5xx, 429, timeout, transport
* failure, unmarked 4xx).
*/
export type WorkerInvokeOutcome =
| 'settled'
| 'terminal'
| 'retriable'
| 'unavailable';
export type WorkerInvokeOutcome = 'settled' | 'terminal' | 'retriable';
export interface WorkerInvokeResult {
outcome: WorkerInvokeOutcome;
/** A deploy on miss was put off because another one held the way. */
deployBusy?: boolean;
/** The status the handler answered with, or `null` when it never did. */
status: number | null;
/** Why it could not answer, when nothing did. */
error?: string;
/** How long the answer asked to be left alone. */
retryAfterMs?: number;
}
/** How one invocation is sent. */
export interface WorkerInvokeOptions {
lane?: InvokeLane;
/** Applied to the event as each attempt is sent. */
stampEvent?: (event: unknown) => unknown;
}
const NO_TRANSPORT =
@@ -297,8 +187,8 @@ export class EventsWorkerInvokerClient extends PuterClient {
/**
* Deploys a script the dispatcher reports missing, in place of the
* rehydrate callback — for the case where that callback lands on a backend
* other than this one — or one still bound to a rotated key. Set once;
* `invoke()` calls it at most once per delivery and never loops.
* other than this one. Set once; `invoke()` calls it at most once per
* delivery and never loops.
*/
setMissHandler(handler: WorkerMissHandler): void {
this.#missHandler = handler;
@@ -309,92 +199,67 @@ export class EventsWorkerInvokerClient extends PuterClient {
this.#dispatcher = null;
}
async invoke(
request: WorkerInvokeRequest,
options: WorkerInvokeOptions = {},
): Promise<WorkerInvokeResult> {
async invoke(request: WorkerInvokeRequest): Promise<WorkerInvokeResult> {
const transport = this.#pickTransport();
if (!transport)
return {
outcome: 'unavailable',
status: null,
error: NO_TRANSPORT,
};
return { outcome: 'retriable', status: null, error: NO_TRANSPORT };
const { stampEvent } = options;
const render = (): string =>
JSON.stringify({
const call: EventsInvokeCall = {
script: request.script,
appUid: request.appUid,
key: request.key,
body: JSON.stringify({
handler: request.handler,
event: stampEvent ? stampEvent(request.event) : request.event,
event: request.event,
ctx: request.ctx,
token: request.token,
});
const send = (deployed: boolean): Promise<EventsInvokeAnswer> =>
transport.send({
script: request.script,
appUid: request.appUid,
key: request.key,
body: render(),
timeoutMs: this.#timeoutMs(),
...(deployed ? { deployed: true } : {}),
...(options.lane ? { lane: options.lane } : {}),
...(stampEvent ? { render } : {}),
});
}),
timeoutMs: this.#timeoutMs(),
};
let result = await send(false);
if (this.#missHandler && selfDeployable(result)) {
const outcome = await this.#missHandler(
request.appUid,
request.script,
{ lane: options.lane },
);
if (outcome !== 'deployed')
return {
outcome: 'unavailable',
status: null,
error: `deploy: ${outcome}`,
...(outcome === DEPLOY_BUSY ? { deployBusy: true } : {}),
};
result = await send(true);
} else if (
result.status === null &&
result.dispatchReason &&
ALREADY_DEPLOYED_MISS_REASONS.has(result.dispatchReason)
) {
// No deploy to trigger — just give propagation the one retry
// it usually needs, without spending an upload on it.
result = await send(true);
let result = await transport.send(call);
if (result.status === null && result.dispatchReason) {
if (
this.#missHandler &&
SELF_DEPLOYABLE_MISS_REASONS.has(result.dispatchReason)
) {
const outcome = await this.#missHandler(
request.appUid,
request.script,
);
if (outcome !== 'deployed')
return {
outcome: 'retriable',
status: null,
error: `deploy: ${outcome}`,
};
result = await transport.send({ ...call, deployed: true });
} else if (
ALREADY_DEPLOYED_MISS_REASONS.has(result.dispatchReason)
) {
// No deploy to trigger — just give propagation the one retry
// it usually needs, without spending an upload on it.
result = await transport.send({ ...call, deployed: true });
}
}
const { status, handled, error, timedOut, retryAfterMs } = result;
if (result.deployBusy)
return {
outcome: 'unavailable',
status: null,
error,
deployBusy: true,
};
// Past the timeout the handler was most likely still running, which
// is its own doing; any other missing answer never reached it.
const { status, handled, error } = result;
if (status === null)
return {
outcome: timedOut ? 'retriable' : 'unavailable',
status: null,
error,
};
return { outcome: 'retriable', status: null, error };
const outcome = outcomeForStatus(status, handled === true, error);
// An unmarked failure is not the handler's own answer; say so even
// when the transport did not, so the reason a delivery kept retrying
// shows up in the log rather than reading as an unexplained retry.
const unmarked = status >= 300 && !handled;
const outcome = outcomeForStatus(status, handled === true);
// An unmarked 4xx (429 aside — that one is always retriable) is not
// the handler's own answer; say so even when the transport did not,
// so the reason a delivery kept retrying shows up in the log rather
// than reading as an unexplained retry.
const unmarkedFourxx =
status !== 429 && status >= 400 && status < 500 && !handled;
const reportedError =
error ?? (unmarked ? `unmarked ${status}` : undefined);
error ?? (unmarkedFourxx ? 'unmarked 4xx' : undefined);
return {
outcome,
status,
...(reportedError ? { error: reportedError } : {}),
...(retryAfterMs ? { retryAfterMs } : {}),
};
}
@@ -405,19 +270,9 @@ export class EventsWorkerInvokerClient extends PuterClient {
// a misconfiguration worth reporting once per attempt rather than a
// round trip.
if (!dispatcherUrl || !internalSecret) return null;
const { invokeBatchSize, invokeBatchWindowMs } =
this.config.events ?? {};
this.#dispatcher ??= new DispatcherInvokeTransport(
dispatcherUrl,
internalSecret,
{
...(invokeBatchSize !== undefined
? { batchSize: invokeBatchSize }
: {}),
...(invokeBatchWindowMs !== undefined
? { batchWindowMs: invokeBatchWindowMs }
: {}),
},
);
return this.#dispatcher;
}
@@ -430,132 +285,52 @@ export class EventsWorkerInvokerClient extends PuterClient {
}
}
/** A call waiting in, or sent with, the current batch. */
interface BatchedCall {
call: EventsInvokeCall;
resolve: (answer: EventsInvokeAnswer) => void;
/** When its wait for a connection began, for what is left of it. */
waitingSince: number;
}
export interface DispatcherInvokeTransportOptions {
fetchImpl?: typeof undiciFetch;
/** Calls per batch; 1 sends every call on its own. */
batchSize?: number;
batchWindowMs?: number;
}
/** Reaches an events worker the only way production can: through the dispatcher. */
export class DispatcherInvokeTransport implements EventsInvokeTransport {
readonly #url: string;
readonly #secret: string;
readonly #fetchImpl: typeof undiciFetch;
readonly #batchSize: number;
readonly #batchWindowMs: number;
#agent: Agent | null = null;
/** Calls holding a connection slot. */
#active = 0;
/** Of those, schedule runs. */
#scheduleActive = 0;
/** Calls waiting for one, longest-waiting first, with their lane. */
readonly #waiting = new Map<() => void, InvokeLane>();
/** Calls gathered for the next batch. */
#batch: BatchedCall[] = [];
#batchTimer: ReturnType<typeof setTimeout> | null = null;
/** Until when the dispatcher is taken not to have the batch route. */
#batchUnsupportedUntil = 0;
constructor(
dispatcherUrl: string,
secret: string,
options: DispatcherInvokeTransportOptions = {},
options: { fetchImpl?: typeof undiciFetch } = {},
) {
// Joined as a string: `new URL(path, base)` resolves an absolute path
// against the base's origin, dropping any path prefix it carries.
this.#url = dispatcherUrl.replace(/\/$/, '');
this.#url = dispatcherUrl;
this.#secret = secret;
this.#fetchImpl = options.fetchImpl ?? undiciFetch;
this.#batchSize = Math.max(
1,
Math.floor(options.batchSize ?? INVOKE_BATCH_SIZE),
);
this.#batchWindowMs = Math.max(
0,
options.batchWindowMs ?? INVOKE_BATCH_WINDOW_MS,
);
}
close(): void {
this.#flush();
void this.#agent?.close().catch(() => {});
this.#agent = null;
}
async send(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
// No `timedOut`: the handler never got this call, so the wait is not
// its failure.
const lane = call.lane ?? 'delivery';
const waitingSince = Date.now();
if (
!(await this.#acquire(call.timeoutMs * CONNECTION_WAIT_SHARE, lane))
)
return {
status: null,
error: 'no free connection to the events dispatcher',
};
async send(call: EventsInvokeCall): Promise<{
status: number | null;
handled?: boolean;
error?: string;
dispatchReason?: string;
}> {
try {
if (this.#batchSize === 1) return await this.#sendOne(call);
return await new Promise<EventsInvokeAnswer>((resolve) => {
this.#batch.push({ call, resolve, waitingSince });
if (this.#batch.length >= this.#batchSize) this.#flush();
else
this.#batchTimer ??= setTimeout(
() => this.#flush(),
this.#batchWindowMs,
);
});
} finally {
this.#release(lane);
}
}
/** Send what has gathered: alone when it is one call, else as a batch. */
#flush(): void {
if (this.#batchTimer) clearTimeout(this.#batchTimer);
this.#batchTimer = null;
const batch = this.#batch;
this.#batch = [];
if (batch.length === 0) return;
if (batch.length === 1 || Date.now() < this.#batchUnsupportedUntil) {
for (const { call, resolve } of batch)
void this.#sendOne(call).then(resolve);
return;
}
void this.#sendBatch(batch);
}
/** One call on the single route. */
async #sendOne(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
try {
const response = await this.#fetchImpl(
`${this.#url}${EVENTS_DISPATCH_PATH}`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
'x-puter-internal-auth': this.#secret,
'x-puter-events-script': call.script,
'x-puter-events-app': call.appUid,
'x-puter-events-key': call.key,
...(call.deployed
? { [EVENTS_DEPLOYED_HEADER]: '1' }
: {}),
},
body: call.render ? call.render() : call.body,
signal: AbortSignal.timeout(call.timeoutMs),
dispatcher: this.#dispatcherFor(),
// `new URL(path, base)` resolves an absolute path against the
// base's origin, dropping any path prefix `base` already carries.
const url = `${this.#url.replace(/\/$/, '')}${EVENTS_DISPATCH_PATH}`;
const response = await this.#fetchImpl(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-puter-internal-auth': this.#secret,
'x-puter-events-script': call.script,
'x-puter-events-app': call.appUid,
'x-puter-events-key': call.key,
...(call.deployed ? { [EVENTS_DEPLOYED_HEADER]: '1' } : {}),
},
);
body: call.body,
signal: AbortSignal.timeout(call.timeoutMs),
dispatcher: this.#dispatcherFor(),
});
// The body is not read: a handler answers with a status, and
// whatever else it writes is its own business.
void response.body?.cancel().catch(() => {});
@@ -566,210 +341,24 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
EVENTS_DISPATCH_ERROR_HEADER,
);
if (dispatchError !== null)
return dispatchAnswer(dispatchError, response.status);
return {
status: null,
error: `dispatcher: ${dispatchError} (${response.status})`,
dispatchReason: dispatchError,
};
return answerFrom(response);
} catch (err) {
return failedAnswer(err);
}
}
/**
* Several calls in one request. A call may wait in the dispatcher's queue
* for whatever is left of its connection wait, and its timeout runs from
* its `started` line; the request itself is dropped once every call has an
* answer.
*/
async #sendBatch(batch: BatchedCall[]): Promise<void> {
const hangUp = new AbortController();
let open = batch.length;
const answered = batch.map(() => false);
const timers: ReturnType<typeof setTimeout>[] = [];
const settle = (i: number, answer: EventsInvokeAnswer): void => {
if (answered[i]) return;
answered[i] = true;
clearTimeout(timers[i]);
batch[i].resolve(answer);
if (--open === 0) hangUp.abort();
};
const settleRest = (answer: EventsInvokeAnswer): void => {
for (let i = 0; i < batch.length; i++) settle(i, answer);
};
const now = Date.now();
const waits = batch.map(({ call, waitingSince }) =>
Math.max(
0,
Math.floor(
call.timeoutMs * CONNECTION_WAIT_SHARE -
(now - waitingSince),
),
),
);
const body = JSON.stringify({
calls: batch.map(({ call }, i) => ({
script: call.script,
appUid: call.appUid,
key: call.key,
...(call.deployed ? { deployed: true } : {}),
body: call.render ? call.render() : call.body,
waitMs: waits[i],
})),
});
const arm = (
i: number,
ms: number,
answer: () => EventsInvokeAnswer,
) => {
clearTimeout(timers[i]);
timers[i] = setTimeout(() => settle(i, answer()), ms);
timers[i].unref?.();
};
// No `timedOut`: a call that never started never reached a handler.
batch.forEach((_, i) =>
arm(i, waits[i] + BATCH_START_GRACE_MS, () => ({
status: null,
error: 'dispatcher: call never started',
})),
);
const started = (i: number): void => {
if (!answered[i])
arm(i, batch[i].call.timeoutMs, () =>
failedAnswer(timeoutError()),
);
};
const longest = Math.max(
...batch.map(
({ call }, i) =>
waits[i] + BATCH_START_GRACE_MS + call.timeoutMs,
),
);
try {
const response = await this.#fetchImpl(
`${this.#url}${EVENTS_DISPATCH_BATCH_PATH}`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
'x-puter-internal-auth': this.#secret,
},
body,
signal: AbortSignal.any([
hangUp.signal,
AbortSignal.timeout(longest),
]),
dispatcher: this.#dispatcherFor(),
},
);
const dispatchError = response.headers.get(
EVENTS_DISPATCH_ERROR_HEADER,
);
if (dispatchError === 'no-such-route') {
// A dispatcher from before the batch route: nothing ran, so
// every call goes again on its own.
void response.body?.cancel().catch(() => {});
this.#batchUnsupportedUntil =
Date.now() + BATCH_UNSUPPORTED_RETRY_MS;
batch.forEach(({ call }, i) => {
if (!answered[i])
void this.#sendOne(call).then((a) => settle(i, a));
});
return;
}
if (dispatchError !== null || !response.ok) {
void response.body?.cancel().catch(() => {});
// An unmarked failure (an edge 404, a WAF 403) reads as
// Puter's, never as any handler's answer.
settleRest(
dispatchError !== null
? dispatchAnswer(dispatchError, response.status)
: {
status: null,
error: `dispatcher: unmarked ${response.status} for a batch`,
},
);
return;
}
let buffered = '';
const read = (line: string): void => {
if (line.trim() === '') return;
const parsed = parseAnswerLine(line);
if (!parsed || parsed.i >= batch.length) return;
if ('started' in parsed) started(parsed.i);
else settle(parsed.i, parsed.answer);
const handled = response.headers.get(EVENTS_HANDLED_HEADER) === '1';
const errorHeader = response.headers.get(EVENTS_ERROR_HEADER);
return {
status: response.status,
handled,
...(errorHeader ? { error: errorHeader } : {}),
};
const decoder = new TextDecoder();
for await (const chunk of response.body ?? []) {
buffered += decoder.decode(chunk, { stream: true });
let newline = buffered.indexOf('\n');
while (newline !== -1) {
read(buffered.slice(0, newline));
buffered = buffered.slice(newline + 1);
newline = buffered.indexOf('\n');
}
}
read(buffered);
settleRest({
status: null,
error: 'dispatcher: batch ended without an answer',
});
} catch (err) {
// Every call has its answer and the request was dropped on purpose.
if (open === 0) return;
settleRest(failedAnswer(err));
}
}
/** Whether a call on `lane` may take a connection now. */
#free(lane: InvokeLane): boolean {
return (
this.#active < INVOKE_CONNECTIONS &&
(lane !== 'schedule' ||
this.#scheduleActive < SCHEDULE_INVOKE_CONNECTIONS)
);
}
#take(lane: InvokeLane): void {
this.#active++;
if (lane === 'schedule') this.#scheduleActive++;
}
/** A connection slot, or false once `waitMs` passes without one. */
#acquire(waitMs: number, lane: InvokeLane): Promise<boolean> {
if (this.#free(lane)) {
this.#take(lane);
return Promise.resolve(true);
}
return new Promise((resolve) => {
const granted = () => {
clearTimeout(timer);
resolve(true);
return {
status: null,
error: err instanceof Error ? err.message : String(err),
};
const timer = setTimeout(() => {
this.#waiting.delete(granted);
resolve(false);
}, waitMs);
timer.unref?.();
this.#waiting.set(granted, lane);
});
}
/**
* Give the slot back, then hand one to the longest waiter it can go to: a
* schedule waiter at a full schedule share doesn't hold up a delivery.
*/
#release(lane: InvokeLane): void {
this.#active--;
if (lane === 'schedule') this.#scheduleActive--;
for (const [granted, waiting] of this.#waiting) {
if (!this.#free(waiting)) continue;
this.#waiting.delete(granted);
this.#take(waiting);
granted();
return;
}
}
@@ -784,120 +373,18 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
}
}
/** What a failure the dispatcher marked as its own reads as. */
const dispatchAnswer = (
reason: string,
status: number,
): EventsInvokeAnswer => ({
status: null,
error: `dispatcher: ${reason} (${status})`,
dispatchReason: reason,
});
/** What `AbortSignal.timeout` rejects with, for a batched call's own timeout. */
const timeoutError = (): Error =>
new DOMException(
'The operation was aborted due to timeout',
'TimeoutError',
);
/** One line of a batch answer, or null when it is not one. */
export const parseAnswerLine = (
line: string,
):
| { i: number; started: true }
| { i: number; answer: EventsInvokeAnswer }
| null => {
let raw: unknown;
try {
raw = JSON.parse(line);
} catch {
return null;
}
if (!raw || typeof raw !== 'object') return null;
const { i, started, status, handled, error, retryAfter, dispatch } =
raw as Record<string, unknown>;
if (!Number.isInteger(i) || (i as number) < 0) return null;
if (started === true) return { i: i as number, started: true };
if (typeof status !== 'number') return null;
if (typeof dispatch === 'string')
return { i: i as number, answer: dispatchAnswer(dispatch, status) };
return {
i: i as number,
answer: answerFrom({
status,
headers: {
get: (name) => {
if (name === EVENTS_HANDLED_HEADER)
return handled === true ? '1' : null;
if (name === EVENTS_ERROR_HEADER)
return typeof error === 'string' ? error : null;
if (name === 'retry-after')
return typeof retryAfter === 'string'
? retryAfter
: null;
return null;
},
},
}),
};
};
/** Whether deploying the script is what fixes this answer. */
const selfDeployable = (result: EventsInvokeAnswer): boolean =>
result.status === null
? result.dispatchReason !== undefined &&
SELF_DEPLOYABLE_MISS_REASONS.has(result.dispatchReason)
: result.handled === true && result.error === BAD_KEY_ERROR;
/** What a script's answer says, read the same way by every transport. */
export const answerFrom = (response: {
status: number;
headers: { get(name: string): string | null };
}): EventsInvokeAnswer => {
const errorHeader = response.headers.get(EVENTS_ERROR_HEADER);
const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'));
return {
status: response.status,
handled: response.headers.get(EVENTS_HANDLED_HEADER) === '1',
...(errorHeader ? { error: errorHeader } : {}),
...(retryAfterMs ? { retryAfterMs } : {}),
};
};
/** An attempt that threw before any answer came back. */
export const failedAnswer = (err: unknown): EventsInvokeAnswer => ({
status: null,
error: err instanceof Error ? err.message : String(err),
// What `AbortSignal.timeout` rejects with.
...((err as { name?: unknown } | null)?.name === 'TimeoutError'
? { timedOut: true }
: {}),
});
/** Whole seconds only; an HTTP-date form reads as absent. */
const parseRetryAfterMs = (value: string | null): number => {
const seconds = Number(value);
return value && Number.isFinite(seconds) && seconds > 0
? seconds * 1000
: 0;
};
/**
* 2xx took it regardless. Anything else not marked as the script's own answer
* came from something between here and the handler. A marked answer is a
* refusal when it is a 4xx other than 429, Puter's failure when the runtime
* names one, and the handler's failure otherwise.
* 2xx took it regardless. 429 is always "not now". Any other 4xx is a refusal
* only when `handled` says the answer is provably the script's own — an
* unmarked 4xx came from something between here and the handler.
*/
export const outcomeForStatus = (
status: number,
handled: boolean,
errorCode?: string,
): WorkerInvokeOutcome => {
if (status >= 200 && status < 300) return 'settled';
if (!handled) return 'unavailable';
if (errorCode && PLATFORM_ERROR_CODES.has(errorCode)) return 'unavailable';
if (status === 429) return 'retriable';
if (status >= 400 && status < 500) return 'terminal';
if (status >= 400 && status < 500)
return handled ? 'terminal' : 'retriable';
return 'retriable';
};
+10 -62
View File
@@ -3,19 +3,18 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { GetObjectCommand } from '@aws-sdk/client-s3';
@@ -25,7 +24,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { IConfig } from '../../types';
import { S3Client, withPresignHeadroom } from './S3Client';
import { S3Client } from './S3Client';
const LOCAL_BUCKET = 'puter-local';
@@ -488,54 +487,3 @@ describe('S3Client — short reads during multipart', () => {
expect(parts).toEqual([10, 10]);
});
});
describe('withPresignHeadroom', () => {
const HOUR = 60 * 60 * 1000;
const creds = (msLeft?: number, id = 'a') => ({
accessKeyId: id,
secretAccessKey: 's',
...(msLeft === undefined
? {}
: { expiration: new Date(Date.now() + msLeft) }),
});
it('reports expiry an hour early so the SDK refreshes while an hour remains', async () => {
const chain = vi.fn(async () => creds(5 * HOUR));
const identity = await withPresignHeadroom(chain)();
expect(chain).toHaveBeenCalledTimes(1);
const left = identity.expiration!.getTime() - Date.now();
expect(left).toBeGreaterThan(4 * HOUR - 1000);
expect(left).toBeLessThanOrEqual(4 * HOUR);
});
it('force-refreshes credentials that are inside the presign window', async () => {
const chain = vi.fn(async (options?: { forceRefresh?: boolean }) =>
options?.forceRefresh
? creds(6 * HOUR, 'fresh')
: creds(30 * 60 * 1000, 'stale'),
);
const identity = await withPresignHeadroom(chain)();
expect(chain).toHaveBeenLastCalledWith(
expect.objectContaining({ forceRefresh: true }),
);
expect(identity.accessKeyId).toBe('fresh');
});
it('leaves long-lived credentials alone', async () => {
const chain = vi.fn(async () => creds(undefined));
const identity = await withPresignHeadroom(chain)();
expect(chain).toHaveBeenCalledTimes(1);
expect(identity.expiration).toBeUndefined();
});
it('never reports an expiry inside the SDK refresh margin', async () => {
const chain = vi.fn(async () => creds(20 * 60 * 1000));
const identity = await withPresignHeadroom(chain)();
const left = identity.expiration!.getTime() - Date.now();
expect(left).toBeGreaterThan(5 * 60 * 1000);
});
});
+1 -40
View File
@@ -43,43 +43,6 @@ const DEFAULT_MULTIPART_PART_SIZE_BYTES = 5 * 1024 * 1024;
const FAUXQS_SAFE_PUT_OBJECT_LIMIT_BYTES = 10 * 1024 * 1024;
const LEGACY_STORAGE_BUCKET = 'puter-local';
// A presigned URL stops working when the credentials that signed it expire.
// Presigns last up to an hour, but the SDK only refreshes temporary
// credentials five minutes before expiry.
const PRESIGN_MAX_LIFETIME_MS = 60 * 60 * 1000;
const SDK_REFRESH_MARGIN_MS = 5 * 60 * 1000;
type CredentialChain = ReturnType<typeof fromNodeProviderChain>;
/**
* Keeps temporary credentials at least an hour from expiry when they sign. The
* SDK is handed each identity with its expiration moved an hour early, so its
* own refresh comes back here while an hour remains, and a credential already
* inside that window is force-refreshed from the chain.
*/
export function withPresignHeadroom(chain: CredentialChain): CredentialChain {
return async (options) => {
let identity = await chain(options);
const remaining = (i: typeof identity) =>
i.expiration ? i.expiration.getTime() - Date.now() : Infinity;
if (
remaining(identity) <
PRESIGN_MAX_LIFETIME_MS + SDK_REFRESH_MARGIN_MS
) {
identity = await chain({ ...options, forceRefresh: true });
}
if (!identity.expiration) return identity;
// If even fresh credentials are short-lived, report an expiry just past
// the SDK's margin rather than one in the past, which would make it
// re-resolve on every request.
const reported = Math.max(
identity.expiration.getTime() - PRESIGN_MAX_LIFETIME_MS,
Date.now() + SDK_REFRESH_MARGIN_MS + 60 * 1000,
);
return { ...identity, expiration: new Date(reported) };
};
}
type S3CommandSender = Pick<AwsS3Client, 'send'>;
export class S3Client extends PuterClient {
@@ -120,9 +83,7 @@ export class S3Client extends PuterClient {
if (useCredentialChain) {
this.useProviderChain = true;
this.awsConfig = {
credentials: withPresignHeadroom(fromNodeProviderChain()),
};
this.awsConfig = { credentials: fromNodeProviderChain() };
this.partSize = 64 * 1024 * 1024;
this.maxSingleUploadSize = 128 * 1024 * 1024;
} else {
@@ -1,85 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
describe('auth elevation cookies over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
it.each(['bearer', 'cookie'])(
'API host returns a usable token without setting cookies (%s auth)',
async (credential) => {
const { token, password, username } = env.users.user;
const res = await fetch(new URL('/auth/elevate', env.apiOrigin), {
method: 'POST',
headers: {
'content-type': 'application/json',
...(credential === 'cookie'
? { cookie: `puter_auth_token=${token}` }
: { authorization: `Bearer ${token}` }),
},
body: JSON.stringify({ password }),
});
expect(res.status).toBe(200);
expect(res.headers.get('set-cookie')).toBeNull();
const body = (await res.json()) as {
elevated: boolean;
token: string;
};
expect(body.elevated).toBe(true);
expect(typeof body.token).toBe('string');
const user = await env.server.stores.user.getByUsername(username);
const { verifyStepUpSession } =
await import('../../core/http/middleware/stepUpSession.js');
expect(
verifyStepUpSession(
{
headers: { 'x-puter-elevation': body.token },
actor: { user: { uuid: user!.uuid } },
} as never,
{ tokenService: env.server.services.token },
),
).toBe(true);
},
);
it('GUI host sets the elevation cookie and omits the raw token for cookie auth', async () => {
const { token, password } = env.users.user;
const res = await fetch(new URL('/auth/elevate', env.origin), {
method: 'POST',
headers: {
'content-type': 'application/json',
cookie: `puter_auth_token=${token}`,
},
body: JSON.stringify({ password }),
});
expect(res.status).toBe(200);
expect(res.headers.get('set-cookie')).toContain('puter_elevated=');
expect(await res.json()).toEqual({ elevated: true });
});
});
@@ -28,6 +28,7 @@ import {
setupPuterTestEnv,
type PuterTestEnv,
} from '../../testUtil.js';
import type { IConfig } from '../../types';
/**
* HTTP-level coverage for `/auth/revoke-own-access-token` — the route that
@@ -423,7 +424,7 @@ describe('2FA enrollment over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
env = await setupPuterTestEnv({ teams_enabled: true } as IConfig);
}, 120_000);
afterAll(async () => {
@@ -560,6 +561,56 @@ describe('2FA enrollment over HTTP', () => {
expect((await otpState(account.userId)).enabled).toBe(true);
});
it('lets a seat its team holds for 2FA enroll through the same gate', async () => {
const owner = await makeAccount();
// The rule can only be turned on by an owner who has 2FA.
await env.server.stores.user.update(owner.userId, { otp_enabled: 1 });
await env.server.stores.user.invalidateById(owner.userId);
const team = await env.server.services.team.createTeam(owner.userId, {
name: 'Acme',
});
const seatName = `seat_${uniq()}`;
const { userId, temporaryPassword } =
await env.server.services.team.provisionAccount(
team.uid,
owner.userId,
{ username: seatName },
);
await env.server.services.team.updateTeam(team.uid, owner.userId, {
require2fa: true,
});
const { token } = await env.server.services.auth.createSessionToken(
(await env.server.stores.user.getById(userId))!,
);
// First sign-in replaces the temporary password.
const changed = await protectedPost(
'/user-protected/change-password',
token,
{ password: temporaryPassword, new_pass: 'seat-own-password' },
);
expect(changed.status).toBe(200);
const held = await fetch(new URL('/teams', env.apiOrigin), {
headers: { authorization: `Bearer ${token}` },
});
expect(held.status).toBe(403);
expect(await held.text()).toContain('two_factor_required');
const res = await setup(token, { password: 'seat-own-password' });
expect(res.status).toBe(200);
const { secret } = (await res.json()) as { secret: string };
const enabled = await configure(token, 'enable', {
code: liveTotp(seatName, secret),
});
expect(enabled.status).toBe(200);
expect((await otpState(userId)).enabled).toBe(true);
const released = await fetch(new URL('/teams', env.apiOrigin), {
headers: { authorization: `Bearer ${token}` },
});
expect(released.status).toBe(200);
});
});
/**
File diff suppressed because it is too large. Load diff
+55 -210
View File
@@ -33,7 +33,7 @@ import {
hasVerifiedPhone,
} from '../../core/http/middleware/gates.js';
import type { Actor } from '../../core/actor.js';
import { isAppActor, isPlainUserActor, makeActor } from '../../core/actor.js';
import { isPlainUserActor, makeActor } from '../../core/actor.js';
import {
checkRateLimit,
consumeRouteRateLimit,
@@ -66,7 +66,6 @@ import {
isCardFallbackEnabled,
phoneAttemptsKey,
} from '../../util/cardFallback.js';
import { isEmailGateLast } from '../../util/verificationOrder.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isStorableEmail } from '../../util/email.js';
import { isGodmodeApp } from '../../util/godmodeApps.js';
@@ -89,16 +88,13 @@ import {
import { promoteToVerifiedGroup } from '../../util/userProvisioning.js';
import {
assertBoundedManageGrant,
assertNewShareSegments,
assertPrefixFitsAccess,
isKvSharePermission,
} from '../../services/kvShare/kvShares.js';
} from '../../services/events/kvShares.js';
import {
APP_DATA_PERMISSION_PREFIX,
appDataSharingAllowed,
parseAppDataPermission,
} from '../../services/permission/appDataScopes.js';
import { parseAppGrantablePermission } from '../../services/permission/appGrantable.js';
import {
assertCreatablePath,
isOwnHomePath,
@@ -115,7 +111,6 @@ import { normalizeAbsolutePath } from '../../services/fs/resolveNode.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import { PuterController } from '../types.js';
import { startWebSession } from './webSession.js';
import { completeOidcLoginReturn } from './oidcLoginReturn.js';
const FINGERPRINT_MAX_LENGTH = 128;
// One consent prompt covers a handful of scopes at most. The cap keeps a
@@ -600,7 +595,6 @@ export class AuthController extends PuterController {
user_uid: string;
purpose: string;
auth_id?: string;
oidcContinuation?: unknown;
}>('otp', token);
} catch {
throw new HttpError(400, 'Invalid token.', {
@@ -634,7 +628,7 @@ export class AuthController extends PuterController {
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
await this.#completeLogin(req, res, user, decoded.oidcContinuation);
await this.#completeLogin(req, res, user);
}
// -- Login: recovery code ----------------------------------------
@@ -670,7 +664,6 @@ export class AuthController extends PuterController {
user_uid: string;
purpose: string;
auth_id?: string;
oidcContinuation?: unknown;
}>('otp', token);
} catch {
throw new HttpError(400, 'Invalid token.', {
@@ -695,11 +688,6 @@ export class AuthController extends PuterController {
}
await this.#assertSecondFactorAttemptsRemain(user.uuid);
if (typeof code !== 'string') {
throw new HttpError(400, 'code must be a string.', {
legacyCode: 'bad_request',
});
}
const hashed = hashRecoveryCode(code);
const codes = ((user.otp_recovery_codes as string) || '')
.split(',')
@@ -712,15 +700,15 @@ export class AuthController extends PuterController {
}
await this.#consumeOtpLoginToken(token);
if (!(await this.stores.user.consumeOtpRecoveryCode(user.id, hashed))) {
await this.#recordSecondFactorFailure(user.uuid);
res.json({ proceed: false });
return;
}
// Consume the recovery code
codes.splice(idx, 1);
await this.stores.user.update(user.id, {
otp_recovery_codes: codes.join(','),
});
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
await this.#completeLogin(req, res, user, decoded.oidcContinuation);
await this.#completeLogin(req, res, user);
}
// -- Signup ------------------------------------------------------
@@ -1221,18 +1209,6 @@ export class AuthController extends PuterController {
}
}
/** The email-gate-last confirmation email; a failure never fails the gate. */
private async sendDeferredConfirmation(userId: number): Promise<void> {
try {
await this.services.signup.sendDeferredConfirmationEmail(userId);
} catch (e) {
console.warn(
'[verification] deferred confirmation email failed:',
e,
);
}
}
private async isCardFallbackEligible(user: {
id: number;
requires_phone_verification?: boolean | number | null;
@@ -1278,12 +1254,6 @@ export class AuthController extends PuterController {
throw new HttpError(403, 'Account suspended.', {
legacyCode: 'account_suspended',
});
if (isEmailGateLast(user) && user.requires_card_verification)
throw new HttpError(
409,
'Card verification must be completed first.',
{ legacyCode: 'conflict' },
);
if (!this.clients.prelude?.isConfigured())
throw await this.smsSendError(
503,
@@ -1354,7 +1324,6 @@ export class AuthController extends PuterController {
user_uid: user.uuid,
phone: parsed.e164,
device_fingerprint: req.deviceFingerprint ?? null,
dispatch_id: dispatchId ?? null,
allowed: true,
reason: null as string | null,
card_fallback_instead: false,
@@ -1593,7 +1562,6 @@ export class AuthController extends PuterController {
requires_phone_verification: 0,
phone: pendingPhone,
});
await this.sendDeferredConfirmation(user.id);
// Run the verified-event listeners synchronously (emitAndWait, not
// fire-and-forget emit) so a carrier-based card-verification waiver in
@@ -1674,11 +1642,7 @@ export class AuthController extends PuterController {
res.json({ card_verified: true });
return;
}
if (
user.requires_phone_verification &&
!fallbackEligible &&
!isEmailGateLast(user)
)
if (user.requires_phone_verification && !fallbackEligible)
throw new HttpError(
409,
'Phone verification must be completed first.',
@@ -1741,7 +1705,6 @@ export class AuthController extends PuterController {
await this.stores.user.update(user.id, {
requires_card_verification: 0,
});
await this.sendDeferredConfirmation(user.id);
res.json({ card_verified: true, disabled: true });
return;
}
@@ -1801,11 +1764,7 @@ export class AuthController extends PuterController {
res.json({ card_verified: true });
return;
}
if (
user.requires_phone_verification &&
!fallbackEligible &&
!isEmailGateLast(user)
)
if (user.requires_phone_verification && !fallbackEligible)
throw new HttpError(
409,
'Phone verification must be completed first.',
@@ -1846,7 +1805,6 @@ export class AuthController extends PuterController {
await this.stores.user.update(user.id, {
requires_card_verification: 0,
});
await this.sendDeferredConfirmation(user.id);
res.json({ card_verified: true, disabled: true });
return;
}
@@ -1871,7 +1829,6 @@ export class AuthController extends PuterController {
requires_card_verification: 0,
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
});
await this.sendDeferredConfirmation(user.id);
// Awaited like `user.phone-verified`, so whatever a listener grants on
// verification is in place before the client refreshes. emitAndWait
@@ -1975,7 +1932,7 @@ export class AuthController extends PuterController {
// A seat's address is admin-supplied and never verified, so whoever
// holds that inbox could take the seat over. Its recovery channel is
// the team admin's password reset, not this one.
if (await this.stores.team?.getOrgSeat(user.id)) {
if (await this.stores.team.getOrgSeat(user.id)) {
res.json({ message: genericMessage });
return;
}
@@ -2209,8 +2166,8 @@ export class AuthController extends PuterController {
// Best effort: the password is already committed, and an audit write
// must not skip the eviction below.
await this.services.team
?.recordPasswordSelfChange(user.id as number)
?.catch((err: unknown) => {
.recordPasswordSelfChange(user.id as number)
.catch((err: unknown) => {
console.warn('[team] password self-change audit failed:', err);
});
@@ -2265,8 +2222,8 @@ export class AuthController extends PuterController {
temp_password_expires_at: null,
});
await this.services.team
?.recordPasswordSelfChange(user.id)
?.catch((err: unknown) => {
.recordPasswordSelfChange(user.id)
.catch((err: unknown) => {
console.warn('[team] password self-change audit failed:', err);
});
@@ -2300,7 +2257,7 @@ export class AuthController extends PuterController {
// A provisioned account's name belongs to the team that made it: the
// console lists its members by username and the audit log records them
// by username, so a self-service rename would desync both.
if (await this.stores.team?.getOrgSeat(req.actor!.user.id!)) {
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
throw new HttpError(
403,
'Your team set this username. Ask a team admin to change it.',
@@ -2434,7 +2391,7 @@ export class AuthController extends PuterController {
async handleChangeEmail(req: Request, res: Response): Promise<void> {
// The address is where admin-issued credentials and team notices go;
// same reasoning as the username and deletion guards above.
if (await this.stores.team?.getOrgSeat(req.actor!.user.id!)) {
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
throw new HttpError(
403,
'Your team set this address. Ask a team admin to change it.',
@@ -3032,45 +2989,6 @@ export class AuthController extends PuterController {
return typeof permission === 'string' && permission ? [permission] : [];
}
/**
* Refuses any entry outside the families an app can be granted. Anything
* else is a row the backend never checks for an app, or one wider than the
* consent prompt can describe.
*/
async #assertAppGrantable(
actor: Actor,
appIdentifier: string,
list: string[],
): Promise<void> {
const refuse = (entry: string) =>
new HttpError(
400,
`Permission cannot be granted to an app: ${entry}`,
{ legacyCode: 'permission_not_grantable' },
);
let appUid: string | undefined;
for (const entry of list) {
// A delegation over a whole key-value namespace has no bounded
// description; refused with its own code.
assertBoundedManageGrant(entry);
const parsed = parseAppGrantablePermission(entry, actor.user!.uuid);
if (!parsed) throw refuse(entry);
if (parsed.family !== 'kvShare') continue;
// Held to what a new share's prefix may be, so a consent never
// covers shares that could not be made.
assertNewShareSegments(parsed.segments);
assertPrefixFitsAccess(
parsed.ownerUuid,
parsed.appUid,
parsed.segments.join(':'),
parsed.level,
);
// An app shares regions of its own namespace only.
appUid ??= (await this.stores.app.resolveApp(appIdentifier))?.uid;
if (parsed.appUid !== appUid) throw refuse(entry);
}
}
/**
* Gate a cross-app data grant: the target must exist, must not have opted
* out of sharing, and must be named. Also creates the target's AppData
@@ -3249,7 +3167,6 @@ export class AuthController extends PuterController {
}
// 400 on a bad value, before anything else runs. Absent means create.
const create = parseCreateFlag(req.body?.create);
await this.#assertAppGrantable(req.actor!, app_uid, list);
// Validate every entry before writing or creating anything, so a bad
// one in the list cannot leave a partially-granted set behind: the
@@ -3262,6 +3179,10 @@ export class AuthController extends PuterController {
kind: FsCreateKind;
}> = [];
for (const entry of list) {
// A delegation over a whole key-value namespace has no bounded
// description, so it is refused where it is asked for rather than
// prompted for and then refused at use.
assertBoundedManageGrant(entry);
await this.#prepareAppDataGrant(req.actor!, entry);
const plan = await this.#planFsPathCreate(
req.actor!,
@@ -3336,14 +3257,6 @@ export class AuthController extends PuterController {
{ legacyCode: 'bad_request' },
);
}
// Only unsharing retires the handle along with its grant; withdrawing
// the grant alone would leave the share listed and dead.
if (typeof permission === 'string' && isKvSharePermission(permission))
throw new HttpError(
400,
'Stop a key-value share with `puter.kv.unshare()` (`DELETE /kv/shares/:handle`)',
{ legacyCode: 'bad_request' },
);
await this.services.permission.revokeUserUserPermission(
req.actor!,
target_username,
@@ -3367,8 +3280,7 @@ export class AuthController extends PuterController {
})
async handleRevokeUserApp(req: Request, res: Response): Promise<void> {
let { app_uid } = req.body ?? {};
const { origin, permission, permissions, meta, deleteData } =
req.body ?? {};
const { origin, permission, permissions, meta } = req.body ?? {};
this.#validateAppPermissionParams({
app_uid,
origin,
@@ -3384,25 +3296,20 @@ export class AuthController extends PuterController {
legacyCode: 'bad_request',
});
}
if (deleteData !== undefined && typeof deleteData !== 'boolean') {
throw new HttpError(400, '`deleteData` must be a boolean', {
legacyCode: 'bad_request',
});
}
if (deleteData && permission !== '*') {
throw new HttpError(
400,
'`deleteData` only applies when `permission` is "*"',
{ legacyCode: 'bad_request' },
);
}
// Deliberately not gated by the target's sharing flag: a user must
// always be able to withdraw a grant, whatever the target now says.
if (permission === '*') {
await this.services.auth.uninstallApp(req.actor!, app_uid, {
deleteData: deleteData === true,
meta: meta ?? undefined,
});
await this.services.permission.revokeUserAppAll(
req.actor!,
app_uid,
meta ?? undefined,
);
// Withdrawing everything also retires the workers the app deployed
// under this user; their tokens would otherwise act as the app.
await this.services.auth.deleteWorkerSessionsForApp(
req.actor!.user!.id as number,
app_uid,
);
} else {
for (const entry of list) {
await this.services.permission.revokeUserAppPermission(
@@ -3482,28 +3389,11 @@ export class AuthController extends PuterController {
const unique = [...new Set(permissions)] as string[];
const result: Record<string, boolean> = {};
// A share grant names its owner and prefix, which its holder is never
// told, so asking about one would confirm a guess. `manage:` arms are
// the app's own consent and stay answerable.
const answerable: string[] = [];
for (const permission of unique) {
if (
typeof permission === 'string' &&
isKvSharePermission(permission)
)
result[permission] = false;
else answerable.push(permission);
}
// `fs:` reaches down a tree, and only ACL walks that. An app asking
// about itself gets the same answer, so a grant on a parent folder
// doesn't read as missing and prompt again.
// `fs:` reaches down a tree, and only ACL walks that.
const viaAcl = new Set(
app_uid || isAppActor(actor)
? answerable.filter((p) => aclAnswerableFsPermission(p))
: [],
app_uid ? unique.filter((p) => aclAnswerableFsPermission(p)) : [],
);
const viaScan = answerable.filter((p) => !viaAcl.has(p));
const viaScan = unique.filter((p) => !viaAcl.has(p));
let granted: Map<string, boolean>;
try {
@@ -3850,9 +3740,8 @@ export class AuthController extends PuterController {
let app = await this.stores.app.getByUid(app_uid);
if (!app && resolvedFromOrigin) {
// Hosted-subdomain and custom-domain origins get the site owner
// stamped as the app's creator at bootstrap; external origins
// stay unowned.
// Hosted-subdomain origins get the site owner stamped as the
// app's creator at bootstrap; external origins stay unowned.
// Canonical origin only, so alternate hosts share one row.
const canonicalOrigin =
this.services.auth.canonicalizeOrigin(origin);
@@ -3868,10 +3757,9 @@ export class AuthController extends PuterController {
// An origin's uid is a deterministic uuidv5, so a deleted app
// reappears here under the identical uid. Withdraw any cross-app
// data grants left pointing at it before this new row can inherit
// consent the user gave its predecessor. Only `createFromOrigin`
// reuses a uid, here or wherever else an origin app is bootstrapped:
// `AppStore.create` mints a random uuid4, which no deleted app can
// ever hold again.
// consent the user gave its predecessor. Only *this* path can reuse
// a uid: `AppStore.create` mints a random uuid4, which no deleted
// app can ever hold again.
//
// Called directly rather than through `app.changed`: the token is
// issued below, so this has to be able to stop that, and
@@ -3882,9 +3770,6 @@ export class AuthController extends PuterController {
app_uid,
'uid reused by a new app',
);
// Key-value shares the predecessor left read as gone already,
// being older than this row; this only cleans them up.
this.services.kvShare.retireSharesOlderThanApp(app);
}
if (!app) {
throw new HttpError(404, `App ${app_uid} does not exist`, {
@@ -4237,7 +4122,7 @@ export class AuthController extends PuterController {
if (action === 'enable') {
// A seat has no email by design, so this would bar it forever.
const seat = await this.stores.team?.getOrgSeat(user.id);
const seat = await this.stores.team.getOrgSeat(user.id);
if (!user.email_confirmed && !seat) {
throw new HttpError(
403,
@@ -4311,7 +4196,7 @@ export class AuthController extends PuterController {
legacyCode: 'not_found',
});
const seat = await this.stores.team?.getOrgSeat(user.id);
const seat = await this.stores.team.getOrgSeat(user.id);
if (seat && Number(seat.require_2fa) === 1) {
throw new HttpError(
409,
@@ -4320,27 +4205,7 @@ export class AuthController extends PuterController {
);
}
const code = req.body?.code;
if (
user.otp_enabled &&
typeof code === 'string' &&
/^[A-Z0-9]{8}$/.test(code)
) {
await this.#assertSecondFactorAttemptsRemain(user.uuid);
const consumed = await this.stores.user.consumeOtpRecoveryCode(
user.id,
hashRecoveryCode(code),
);
if (!consumed) {
await this.#recordSecondFactorFailure(user.uuid);
throw new HttpError(401, 'Incorrect code.', {
legacyCode: 'code_mismatch',
fields: { factor: 'otp' },
});
}
} else {
this.#requireAccountTwoFactor(user, code);
}
this.#requireAccountTwoFactor(user, req.body?.code);
await this.stores.user.update(user.id, {
otp_enabled: false,
@@ -4550,14 +4415,11 @@ export class AuthController extends PuterController {
}
const token = signStepUpToken(this.services.token, user as never);
const isApiHost = req.subdomains?.at(-1) === 'api';
if (!isApiHost) {
res.cookie(
STEP_UP_COOKIE_NAME,
token,
stepUpCookieOptions(this.config),
);
}
res.cookie(
STEP_UP_COOKIE_NAME,
token,
stepUpCookieOptions(this.config),
);
// A browser reads its elevation back from the httpOnly cookie and never
// needs the raw value; handing it to page JS would put the second factor
@@ -4566,9 +4428,7 @@ export class AuthController extends PuterController {
// password/TOTP — this only avoids needless exposure, it isn't a gate.
const cookieName = this.config.cookie_name ?? 'puter_token';
const usedSessionCookie =
!isApiHost &&
!!req.token &&
req.token === req.cookies?.[cookieName];
!!req.token && req.token === req.cookies?.[cookieName];
res.json(
usedSessionCookie ? { elevated: true } : { elevated: true, token },
);
@@ -4589,7 +4449,7 @@ export class AuthController extends PuterController {
const userId = req.actor!.user.id!;
// The team owns the account and is billed for it; only they may close
// it, through the console that keeps the audit trail.
if (await this.stores.team?.getOrgSeat(userId)) {
if (await this.stores.team.getOrgSeat(userId)) {
throw new HttpError(
403,
'Your team owns this account. Ask a team admin to remove it.',
@@ -4599,7 +4459,7 @@ export class AuthController extends PuterController {
// The other direction: deleting an owner sets `owner_user_id` NULL and
// cascades their membership, leaving a team no one can administer —
// not even an admin, since every team route resolves by membership.
if (await this.stores.team?.countOwned(userId)) {
if (await this.stores.team.countOwned(userId)) {
throw new HttpError(
403,
'Delete the teams you own before closing this account.',
@@ -4608,7 +4468,7 @@ export class AuthController extends PuterController {
}
// A deleted team's seats are suspended, not gone, and only its owner
// can retire them.
if (await this.stores.team?.countOwnedTeamsWithAccounts(userId)) {
if (await this.stores.team.countOwnedTeamsWithAccounts(userId)) {
throw new HttpError(
403,
'Close the accounts your teams created before closing this one.',
@@ -4681,19 +4541,13 @@ export class AuthController extends PuterController {
await checkRateLimit(`${scope}:${userUid}`, limit, window);
}
/**
* An `otp-login` JWT completes one sign-in; a second redemption is refused.
* Fails closed: while the store can't say whether the token was used, the
* sign-in is refused rather than letting a captured token replay.
*/
/** An `otp-login` JWT completes one sign-in; a second redemption is refused. */
async #consumeOtpLoginToken(token: string): Promise<void> {
const digest = crypto.createHash('sha256').update(token).digest('hex');
const firstUse = await checkRateLimit(
`login-otp-token-used:${digest}`,
1,
OTP_LOGIN_TOKEN_USED_WINDOW_MS,
undefined,
{ failOpen: false },
);
if (!firstUse) {
throw new HttpError(400, 'Invalid token.', {
@@ -4767,15 +4621,7 @@ export class AuthController extends PuterController {
req: Request,
res: Response,
user: UserRow,
oidcContinuation?: unknown,
): Promise<void> {
const redirectUrl =
oidcContinuation === undefined
? undefined
: completeOidcLoginReturn(res, user, oidcContinuation, {
config: this.config,
oidc: this.services.oidc,
});
const gui_token = await startWebSession(req, res, user, {
config: this.config,
auth: this.services.auth,
@@ -4801,7 +4647,6 @@ export class AuthController extends PuterController {
next_step: 'complete',
token: gui_token,
user: details,
...(redirectUrl ? { redirectUrl } : {}),
});
}
}
@@ -6,7 +6,7 @@ import {
setupPuterTestEnv,
type PuterTestEnv,
} from '../../testUtil.js';
import { installSeatStub } from '../../testFixtures/seatStub.js';
import type { IConfig } from '../../types.js';
describe('account change second-factor verification', () => {
let env: PuterTestEnv;
@@ -14,7 +14,7 @@ describe('account change second-factor verification', () => {
const links = new Map<string, string>();
beforeAll(async () => {
env = await setupPuterTestEnv();
env = await setupPuterTestEnv({ teams_enabled: true } as IConfig);
vi.spyOn(env.server.clients.email, 'send').mockImplementation(
async (to, template, values) => {
if (template === 'email_verification_link')
@@ -333,8 +333,15 @@ describe('account change second-factor verification', () => {
});
it('does not let a valid code override a team requirement for 2FA', async () => {
const owner = await account();
const member = await account(true, true);
installSeatStub(env.server as never, [member.id], {
const team = await env.server.services.team.createTeam(owner.id, {
name: 'Required 2FA',
});
await env.server.stores.team.addMember(team.uid, member.id, {
orgOwned: true,
});
await env.server.services.team.updateTeam(team.uid, owner.id, {
require2fa: true,
});
const proof = env.server.services.token.sign('oidc-state', {
@@ -1,96 +0,0 @@
import crypto from 'node:crypto';
import type { Response } from 'express';
import { HttpError } from '../../core/http/HttpError.js';
import type { OIDCService } from '../../services/auth/OIDCService.js';
import type { UserRow } from '../../stores/user/UserStore.js';
import type { IConfig } from '../../types.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
export const OIDC_POPUP_RETURN_COOKIE_NAME = 'puter_oidc_popup_return';
export const OIDC_POPUP_RETURN_EXPIRY_SEC = 300;
export interface OidcLoginContinuation {
userUuid: string;
redirectUri: string;
embeddedInPopup: boolean;
openerOrigin: string | null;
messageId: string | null;
switchedToLogin: boolean;
}
interface OidcReturnDeps {
config: IConfig;
oidc: OIDCService;
}
/** Bind an attested popup return to the browser that completed sign-in. */
export function mintOidcPopupReturn(
res: Response,
payload: Record<string, unknown>,
deps: OidcReturnDeps,
): string {
const nonce = crypto.randomBytes(32).toString('base64url');
res.cookie(OIDC_POPUP_RETURN_COOKIE_NAME, nonce, {
...sessionCookieFlags(deps.config, { crossSite: false }),
httpOnly: true,
maxAge: OIDC_POPUP_RETURN_EXPIRY_SEC * 1000,
path: '/',
});
return deps.oidc.signPopupReturn({ action: 'sign-in', ...payload, nonce });
}
/** Call only after both the provider and the account's second factor succeed. */
export function completeOidcLoginReturn(
res: Response,
user: Pick<UserRow, 'uuid'>,
continuation: unknown,
deps: OidcReturnDeps,
): string {
if (!continuation || typeof continuation !== 'object') {
throw new HttpError(400, 'Invalid login continuation.', {
legacyCode: 'bad_request',
});
}
const data = continuation as OidcLoginContinuation;
if (
data.userUuid !== user.uuid ||
typeof data.redirectUri !== 'string' ||
typeof data.embeddedInPopup !== 'boolean' ||
typeof data.switchedToLogin !== 'boolean' ||
(data.openerOrigin !== null && typeof data.openerOrigin !== 'string') ||
(data.messageId !== null && typeof data.messageId !== 'string')
) {
throw new HttpError(400, 'Invalid login continuation.', {
legacyCode: 'bad_request',
});
}
const origin = (deps.config.origin ?? '').replace(/\/$/, '');
let target = data.redirectUri || origin || '/';
try {
if (origin && new URL(target).origin !== new URL(origin).origin)
target = origin;
} catch {
target = origin || '/';
}
const append = (key: string, value: string) => {
target += `${target.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
};
if (data.embeddedInPopup) {
append('oidc_login', 'true');
append(
'opener_state',
mintOidcPopupReturn(
res,
{
opener_origin: data.openerOrigin,
msg_id: data.messageId,
oidc_login: true,
user_uuid: user.uuid,
},
deps,
),
);
}
if (data.switchedToLogin) append('oidc_switched', 'login');
return target;
}
@@ -1,139 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
import {
createTestUser,
setupPuterTestEnv,
type PuterTestEnv,
} from '../../testUtil.js';
describe('recovery code consumption', () => {
let env: PuterTestEnv;
const password = 'recovery-code-password';
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
afterEach(() => {
vi.restoreAllMocks();
});
// Hold the same cached snapshot until every concurrent request has read it.
const overlapReads = (userUuid: string, count: number) => {
const redis = env.server.clients.redis;
const get = redis.get.bind(redis);
const ready = Promise.withResolvers<void>();
let reads = 0;
vi.spyOn(redis, 'get').mockImplementation(async (key: string) => {
const value = await get(key);
if (key.includes(userUuid) && reads < count) {
if (++reads === count) ready.resolve();
await ready.promise;
}
return value;
});
};
const post = async (path: string, body: Record<string, unknown>) => {
const response = await fetch(new URL(path, env.origin), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
return { response, body: await response.json() };
};
const account = async (codes: string[]) => {
const username = `recovery_${uuidv4().slice(0, 8)}`;
await createTestUser(env.server, { username, password });
const user = (await env.server.stores.user.getByUsername(username))!;
await env.server.stores.user.update(user.id, {
otp_enabled: true,
otp_secret: 'TESTSECRETBASE32',
otp_recovery_codes: codes.map(hashRecoveryCode).join(','),
});
const challenge = async () => {
const { response, body } = await post('/login', {
username,
password,
});
expect(response.status).toBe(202);
expect(body.next_step).toBe('otp');
return body.otp_jwt_token as string;
};
return { user, challenge };
};
it('allows only one concurrent redemption of the same code', async () => {
const { user, challenge } = await account(['RECOVER1', 'RECOVER2']);
const tokens = await Promise.all([challenge(), challenge()]);
overlapReads(user.uuid, tokens.length);
const results = await Promise.all(
tokens.map((token) =>
post('/login/recovery-code', { token, code: 'RECOVER1' }),
),
);
expect(
results.filter((result) => result.body.next_step === 'complete'),
).toHaveLength(1);
expect(
results.filter((result) => result.body.proceed === false),
).toHaveLength(1);
const after = await env.server.stores.user.getById(user.id, {
force: true,
});
expect(after!.otp_recovery_codes).toBe(hashRecoveryCode('RECOVER2'));
});
it('consumes different concurrent codes without restoring either one', async () => {
const codes = ['RECOVER1', 'RECOVER2', 'RECOVER3'];
const { user, challenge } = await account(codes);
const tokens = await Promise.all(codes.map(() => challenge()));
overlapReads(user.uuid, tokens.length);
const results = await Promise.all(
codes.map((code, index) =>
post('/login/recovery-code', { token: tokens[index], code }),
),
);
expect(
results.every((result) => result.body.next_step === 'complete'),
).toBe(true);
const after = await env.server.stores.user.getById(user.id, {
force: true,
});
expect(after!.otp_recovery_codes).toBe('');
const replay = await post('/login/recovery-code', {
token: tokens[0],
code: codes[0],
});
expect(replay.body).toMatchObject({ proceed: false });
});
it.each([{ code: 123456 }, { code: {} }, { code: [] }])(
'rejects a non-string recovery code $code without consuming a code',
async ({ code }) => {
const { user, challenge } = await account(['RECOVER1']);
const result = await post('/login/recovery-code', {
token: await challenge(),
code,
});
expect(result.response.status).toBe(400);
expect(
(await env.server.stores.user.getById(user.id, {
force: true,
}))!.otp_recovery_codes,
).toBe(hashRecoveryCode('RECOVER1'));
},
);
});
@@ -1,89 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
import {
createTestUser,
setupPuterTestEnv,
type PuterTestEnv,
} from '../../testUtil.js';
describe('2FA after signing in with a recovery code', () => {
let env: PuterTestEnv;
const password = 'lost-authenticator-password';
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const post = (
path: string,
body: Record<string, unknown>,
cookie?: string,
) =>
fetch(new URL(path, env.origin), {
method: 'POST',
headers: {
'content-type': 'application/json',
...(cookie ? { cookie } : {}),
},
body: JSON.stringify(body),
});
it('allows an unused recovery code to disable 2FA after password revalidation', async () => {
const username = `lost2fa_${uuidv4().slice(0, 8)}`;
await createTestUser(env.server, { username, password });
const user = (await env.server.stores.user.getByUsername(username))!;
await env.server.stores.user.update(user.id, {
email: `${username}@example.com`,
email_confirmed: true,
otp_enabled: true,
otp_secret: 'TESTSECRETBASE32',
otp_recovery_codes: ['RECOVER1', 'RECOVER2', 'RECOVER3']
.map(hashRecoveryCode)
.join(','),
});
// Sign in with a recovery code: works, and yields a web session.
const login = await post('/login', { username, password });
expect(login.status).toBe(202);
const { otp_jwt_token } = await login.json();
const recovered = await post('/login/recovery-code', {
token: otp_jwt_token,
code: 'RECOVER1',
});
expect((await recovered.json()).next_step).toBe('complete');
const cookie = recovered.headers
.getSetCookie()
.find((value) => value.startsWith('puter_auth_token='))!
.split(';')[0]!;
const reused = await post(
'/user-protected/disable-2fa',
{ password, code: 'RECOVER1' },
cookie,
);
expect(reused.status).toBe(401);
expect(
(await env.server.stores.user.getById(user.id, { force: true }))!
.otp_enabled,
).toBeTruthy();
const disable = await post(
'/user-protected/disable-2fa',
{ password, code: 'RECOVER2' },
cookie,
);
expect(disable.status).toBe(200);
expect(await disable.json()).toMatchObject({ success: true });
const after = await env.server.stores.user.getById(user.id, {
force: true,
});
expect(after!.otp_enabled).toBeFalsy();
expect(after!.otp_secret).toBeNull();
expect(after!.otp_recovery_codes).toBeNull();
});
});
@@ -19,7 +19,6 @@
import type { Request, Response } from 'express';
import { Controller, Post } from '../../core/http/decorators.js';
import { schedulesUnavailable } from '../../core/schedules.js';
import type { BroadcastService } from '../../services/broadcast/BroadcastService.js';
import type { ForwardBatch } from '../../services/events/forwardQueue.js';
import { PuterController } from '../types.js';
@@ -109,42 +108,6 @@ export class BroadcastController extends PuterController {
);
res.status(200).json({ ok: true, ...reply });
}
/**
* Schedule changes a region made for schedules this one owns, handed to
* `services.schedules`; `events_disabled` on a server without it.
*/
@Post('/events/schedules', { subdomain: '*' })
async schedules(req: Request, res: Response): Promise<void> {
const broadcast = this.services.broadcast as unknown as
BroadcastService | undefined;
if (!broadcast) {
res.status(503).json({
error: { message: 'Broadcast service not registered' },
});
return;
}
const verified = await broadcast.verifySignedRequest(
req.rawBody,
broadcastHeaders(req),
);
if (!verified.ok) {
res.status(verified.status ?? 400).json({
error: { message: verified.message ?? 'Bad request' },
});
return;
}
if (verified.info?.ignored) {
res.status(200).json({ ok: true, ...verified.info });
return;
}
const schedules = this.services.schedules;
if (!schedules) throw schedulesUnavailable();
const reply = await schedules.receivePeerChanges(req.body);
res.status(200).json({ ok: true, ...reply });
}
}
const broadcastHeaders = (req: Request) => {
@@ -21,7 +21,7 @@
import { EventEmitter } from 'node:events';
import type { Request, RequestHandler, Response } from 'express';
import { beforeEach, describe, expect, it } from 'vitest';
import { Context, runWithContext } from '../../core/context.js';
import { runWithContext } from '../../core/context.js';
import { isHttpError } from '../../core/http/HttpError.js';
import { configureRateLimit } from '../../core/http/middleware/rateLimit.js';
import { DriverController } from './DriverController.js';
@@ -278,86 +278,6 @@ describe('DriverController — concurrent acquire/release', () => {
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
});
// A driver that keeps working after the caller hangs up (async video
// polling) must keep counting against the cap until it settles.
it('holds the slot while the driver still works after a disconnect', async () => {
let finish = () => {};
const driver = {
...makeSyntheticDriver(),
ping: () =>
new Promise<string>((resolve) => {
finish = () => resolve('pong');
}),
};
const handler = captureCallHandler(buildController(driver));
const req = () =>
makeReq({ interface: 'test-iface', method: 'ping' }, 'working');
const res1 = new StubRes();
const first = callInScope(handler, req(), res1 as unknown as Response);
await new Promise((r) => setImmediate(r));
res1.emit('close');
await new Promise((r) => setImmediate(r));
await expect(
callInScope(handler, req(), new StubRes() as unknown as Response),
).rejects.toSatisfy(
(e) =>
isHttpError(e) &&
(e as { statusCode: number }).statusCode === 429,
);
finish();
await first;
await new Promise((r) => setImmediate(r));
const res2 = new StubRes();
const second = callInScope(handler, req(), res2 as unknown as Response);
await new Promise((r) => setImmediate(r));
finish();
await second;
res2.emit('finish');
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
});
it('holds the slot for work a driver keeps past its response', async () => {
let settleWork = () => {};
const driver = {
...makeSyntheticDriver(),
ping: async () => {
Context.get('keepDriverSlot')?.(
new Promise<void>((resolve) => {
settleWork = resolve;
}),
);
return 'pong';
},
};
const handler = captureCallHandler(buildController(driver));
const req = () =>
makeReq({ interface: 'test-iface', method: 'ping' }, 'kept');
const res1 = new StubRes();
await callInScope(handler, req(), res1 as unknown as Response);
res1.emit('finish');
await new Promise((r) => setImmediate(r));
await expect(
callInScope(handler, req(), new StubRes() as unknown as Response),
).rejects.toSatisfy(
(e) =>
isHttpError(e) &&
(e as { statusCode: number }).statusCode === 429,
);
settleWork();
await new Promise((r) => setImmediate(r));
const res2 = new StubRes();
await callInScope(handler, req(), res2 as unknown as Response);
res2.emit('finish');
settleWork();
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
});
it('does not attach release listeners when the driver declares no concurrent config', async () => {
// With no spec there is nothing to release, so the gate must not
// wire `finish`/`close` release listeners. The client-disconnect
@@ -736,42 +736,6 @@ describe('DriverController driver-method lifecycle events', () => {
rejectReason: 'blocked in test',
});
});
it('answers 429 when a before listener vetoes with rejectStatus 429', async () => {
const actor = await makeUserActor();
await grantKvPerm(actor);
const key = `throttle-${uuidv4()}`;
server.clients.event.on(
'driver.puter-kvstore.set.before',
(_k, data) => {
if ((data.args as { key?: string })?.key === key) {
data.allow = false;
data.rejectStatus = 429;
}
},
);
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key, value: 'v' },
},
actor,
);
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'too_many_requests',
});
});
});
describe('DriverController.#handleListInterfaces', () => {
@@ -31,7 +31,6 @@ import {
checkDriverRateLimit,
} from '../../core/http/middleware/rateLimit.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import type { LifecycleRejectStatus } from '../../clients/event/types.js';
import type { DriverMeta } from '../../drivers/meta.js';
import {
isCreditExhaustion,
@@ -51,7 +50,6 @@ import { PermissionUtil } from '../../services/permission/permissionUtil.js';
import type { WithLifecycle } from '../../types';
import { withSpan } from '../../util/span.js';
import { PuterController } from '../types.js';
import { CallSlot } from './callSlot.js';
type DriverInstance = WithLifecycle & Record<string, unknown>;
@@ -261,17 +259,6 @@ export class DriverController extends PuterController {
// -- Handlers ----------------------------------------------------
#handleCall = async (req: Request, res: Response): Promise<void> => {
const slot = new CallSlot();
const call = this.#call(req, res, slot);
slot.keep(call);
await call;
};
#call = async (
req: Request,
res: Response,
slot: CallSlot,
): Promise<void> => {
const {
interface: ifaceName,
method,
@@ -335,8 +322,7 @@ export class DriverController extends PuterController {
if (req.actor) {
const permService = this.services.permission as unknown as
| PermissionService
| undefined;
PermissionService | undefined;
if (permService) {
// Build via PermissionUtil.join so any `:` in a driver or
// interface name is escaped — raw interpolation would let a
@@ -409,9 +395,10 @@ export class DriverController extends PuterController {
// Per-method rate-limit and concurrent specs both live on the
// driver's resolved meta. Rate-limit is single-shot; concurrent
// acquires a slot held until the response is done and the call's
// work has settled, so streamed responses hold it until the stream
// drains and work that continues after a disconnect still counts.
// acquires a slot that must be released when the response is done
// — we hook `res.finish` / `res.close` for that so streamed
// responses hold their slot until the stream drains, and aborted
// requests still give the slot back.
const rateLimitSpec = resolvePerMethod(driverMeta?.rateLimit, method);
if (
!(await checkDriverRateLimit(req, ifaceName, method, rateLimitSpec))
@@ -443,8 +430,18 @@ export class DriverController extends PuterController {
legacyCode: 'too_many_requests',
});
}
slot.attach(handle, res);
Context.set('keepDriverSlot', (work) => slot.keep(work));
let released = false;
const release = () => {
if (released) return;
released = true;
void handle.release();
};
// If the handler throws before responding, the express error
// handler will eventually send a response — `finish` fires then,
// so we still release. `close` covers client aborts.
res.once('finish', release);
res.once('close', release);
if (res.destroyed || res.writableFinished) release();
}
// Stash the requested driver name in Context so multi-provider
@@ -477,7 +474,6 @@ export class DriverController extends PuterController {
args,
allow: true as boolean,
rejectReason: undefined as string | undefined,
rejectStatus: undefined as LifecycleRejectStatus | undefined,
};
await this.clients.event?.emitAndWait(
`driver.${ifaceName}.${method}.before`,
@@ -499,12 +495,11 @@ export class DriverController extends PuterController {
},
{},
);
const throttled = beforeEvent.rejectStatus === 429;
throw new HttpError(
throttled ? 429 : 403,
403,
beforeEvent.rejectReason ??
`Blocked by policy: ${ifaceName}:${method}`,
{ legacyCode: throttled ? 'too_many_requests' : 'forbidden' },
{ legacyCode: 'forbidden' },
);
}
@@ -1,73 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { EventEmitter } from 'node:events';
import type { Response } from 'express';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CallSlot } from './callSlot.js';
const handle = () => ({
release: vi.fn(() => Promise.resolve()),
renew: vi.fn(() => Promise.resolve()),
});
const deferred = () => {
let resolve = () => {};
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
};
afterEach(() => {
vi.useRealTimers();
});
describe('CallSlot', () => {
it('renews a slot held by a long call and stops once it is released', async () => {
vi.useFakeTimers();
const slot = new CallSlot();
const slotHandle = handle();
const res = new EventEmitter();
const call = deferred();
slot.keep(call.promise);
slot.attach(slotHandle, res as unknown as Response);
res.emit('close');
await vi.advanceTimersByTimeAsync(25 * 60 * 1000);
expect(slotHandle.renew).toHaveBeenCalledTimes(2);
expect(slotHandle.release).not.toHaveBeenCalled();
call.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(slotHandle.release).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(25 * 60 * 1000);
expect(slotHandle.renew).toHaveBeenCalledTimes(2);
});
it('releases once the response is done if no work is outstanding', async () => {
const slot = new CallSlot();
const slotHandle = handle();
const res = new EventEmitter();
slot.attach(slotHandle, res as unknown as Response);
res.emit('finish');
expect(slotHandle.release).toHaveBeenCalledOnce();
expect(slotHandle.renew).not.toHaveBeenCalled();
});
});
@@ -1,80 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Response } from 'express';
// Under the concurrency store's one-hour orphan TTL.
const SLOT_RENEW_MS = 10 * 60 * 1000;
interface SlotHandle {
release(): Promise<void>;
renew?(): Promise<void>;
}
/**
* A driver call's concurrency slot, held until the response is done and the
* call's work has settled, including work a driver carries on after the caller
* hung up or was answered.
*/
export class CallSlot {
#handle?: SlotHandle;
#work = new Set<Promise<unknown>>();
#responded = false;
#released = false;
#renew?: NodeJS.Timeout;
attach(handle: SlotHandle, res: Response): void {
this.#handle = handle;
const responded = () => {
this.#responded = true;
this.#maybeRelease();
};
res.once('finish', responded);
res.once('close', responded);
if (res.destroyed || res.writableFinished) responded();
if (!this.#released && this.#work.size > 0) this.#startRenewing();
}
keep(work: Promise<unknown>): void {
if (this.#released) return;
this.#work.add(work);
if (this.#handle) this.#startRenewing();
const settled = () => {
this.#work.delete(work);
this.#maybeRelease();
};
work.then(settled, settled);
}
#startRenewing(): void {
if (this.#renew) return;
this.#renew = setInterval(() => {
void this.#handle?.renew?.();
}, SLOT_RENEW_MS);
this.#renew.unref?.();
}
#maybeRelease(): void {
if (this.#released || !this.#handle) return;
if (!this.#responded || this.#work.size > 0) return;
this.#released = true;
clearInterval(this.#renew);
void this.#handle.release();
}
}
@@ -25,11 +25,6 @@ import { bodyRecord } from '../../core/http/requestBody.js';
import { DURABLE_LIST_LIMIT_CAP } from '../../stores/events/DurableSubscriptionStore.js';
import { EVENTS_WORKERS_LIST_LIMIT_CAP } from '../../stores/events/EventHandlerStore.js';
import { KV_HANDLE_LIST_LIMIT_CAP } from '../../stores/events/KvShareHandleStore.js';
import {
SCHEDULE_LIST_LIMIT_CAP,
schedulesUnavailable,
type ScheduleServiceContract,
} from '../../core/schedules.js';
import { normalizeLimit } from '../../util/pagination.js';
import { PuterController } from '../types.js';
import { EVENTS_WORKER_PREFIX } from '../../services/events/workerRuntime.js';
@@ -109,24 +104,6 @@ export class EventsController extends PuterController {
});
}
/**
* POST /events/subscriptions/update — replace one subscription's `context`
* in place. An id the caller does not hold reads as absent.
*/
@Post('/subscriptions/update', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
})
async update(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const { sub } = await this.services.events.updateDurable(
actor,
bodyRecord(req),
);
res.json(sub);
}
/**
* GET /events/fetch — what the caller missed, one page at a time.
*
@@ -174,94 +151,11 @@ export class EventsController extends PuterController {
res.json({});
}
// -- Schedules ---------------------------------------------------
//
// An app's handler run on a timer, served by `services.schedules` when a
// server has it and answered `events_disabled` when it doesn't. Creating
// one needs an app context; listing, reading and removing are scoped the
// way the durable verbs are. The create and remove verbs spend the
// subscribe and unsubscribe budgets in the service, shared with those
// verbs.
#schedules(): ScheduleServiceContract {
const schedules = this.services.schedules;
if (!schedules) throw schedulesUnavailable();
return schedules;
}
/** POST /events/schedules — create a schedule for the calling app. */
@Post('/schedules', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
})
async createSchedule(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
res.json(await this.#schedules().createFor(actor, bodyRecord(req)));
}
/** GET /events/schedules — the caller's schedules, one page at a time. */
@Get('/schedules', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
rateLimit: EVENTS_LIST_LIMIT,
})
async listSchedules(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const query = (req.query ?? {}) as Record<string, unknown>;
const page = await this.#schedules().list(actor, {
limit: normalizeLimit(query.limit, {
cap: SCHEDULE_LIST_LIMIT_CAP,
}),
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
includeTotal: query.includeTotal === 'true',
});
res.json({
items: page.items,
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total !== undefined ? { total: page.total } : {}),
});
}
/**
* POST /events/schedules/remove — end one schedule. An id the caller does
* not hold reads as absent.
*/
@Post('/schedules/remove', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
})
async removeSchedule(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
await this.#schedules().remove(actor, bodyRecord(req).scheduleId);
res.json({});
}
/**
* GET /events/schedules/:scheduleId — one schedule, ended or not. An id the
* caller does not hold reads as absent.
*/
@Get('/schedules/:scheduleId', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
rateLimit: EVENTS_LIST_LIMIT,
})
async getSchedule(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
res.json(await this.#schedules().get(actor, req.params.scheduleId));
}
// -- Cross-user key-value handles --------------------------------
/**
* POST /events/kv-handles — hand another user a region of this account's
* key-value data, `watch` unless the body asks for more. The older name for
* `POST /kv/shares`, keeping its own body and response shapes.
* POST /events/kv-handles — hand another user a watchable region of this
* account's key-value data.
*
* An account session, or an app session holding a `manage:` delegation on
* the region — the service tells those apart and gates each rather than the
@@ -273,32 +167,12 @@ export class EventsController extends PuterController {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
requireVerified: true,
})
async mintKvHandle(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const body = bodyRecord(req);
const share = await this.services.kvShare.mintKvHandle(
actor,
{
prefix: body.prefix,
recipient: {
username:
typeof body.granteeUsername === 'string'
? body.granteeUsername
: undefined,
uid:
typeof body.granteeUid === 'string'
? body.granteeUid
: undefined,
},
access: body.access,
appUid: body.appUid,
},
// Callers of this route mint again to get the same handle back.
{ defaultAccess: 'watch', keepLevelUnlessNamed: true },
res.json(
await this.services.events.mintKvHandle(actor, bodyRecord(req)),
);
res.json({ handle: share.handle, prefix: share.prefix });
}
/**
@@ -315,7 +189,7 @@ export class EventsController extends PuterController {
const actor = this.#requireActor(req);
const query = (req.query ?? {}) as Record<string, unknown>;
const page = await this.services.kvShare.listKvHandles(actor, {
const page = await this.services.events.listKvHandles(actor, {
limit: normalizeLimit(query.limit, {
cap: KV_HANDLE_LIST_LIMIT_CAP,
}),
@@ -324,10 +198,7 @@ export class EventsController extends PuterController {
});
res.json({
items: page.items.map(({ holderUsername, ...row }) => ({
...row,
granteeUsername: holderUsername,
})),
items: page.items,
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total !== undefined ? { total: page.total } : {}),
});
@@ -346,10 +217,7 @@ export class EventsController extends PuterController {
async revokeKvHandle(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
res.json(
await this.services.kvShare.revokeKvHandle(
actor,
req.params.handle,
),
await this.services.events.revokeKvHandle(actor, req.params.handle),
);
}
@@ -512,14 +380,10 @@ export class EventsController extends PuterController {
res.json({ deployed: true });
return;
}
// `failed`, `throttled` and `busy` are worth another attempt: the rest
// say this script is not what the app's handlers currently are.
// `failed` and `throttled` are worth another attempt: the rest say
// this script is not what the app's handlers currently are.
res.status(
outcome === 'failed' ||
outcome === 'throttled' ||
outcome === 'busy'
? 502
: 404,
outcome === 'failed' || outcome === 'throttled' ? 502 : 404,
).json({
deployed: false,
reason: outcome,
@@ -546,8 +410,8 @@ export class EventsController extends PuterController {
);
return;
}
this.services.events.useWorkerMissHandler((appUid, script, options) =>
deployer.ensure(appUid, script, options),
this.services.events.useWorkerMissHandler((appUid, script) =>
deployer.ensure(appUid, script),
);
}
+11 -25
View File
@@ -41,30 +41,16 @@ import {
EVENTS_SINGLE_DELIVERY_LIMIT,
EVENTS_WORKER_INVOCATION_LIMIT,
limitFor,
tiered,
type TieredLimit,
} from './limits.js';
const tiers: Array<[string, TieredLimit]> = [
[
'durable subscriptions per account',
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
],
['durable subscriptions per account', EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER],
['durable subscriptions per app', EVENTS_DURABLE_SUBSCRIPTIONS_PER_APP],
['live share handles per account', EVENTS_KV_HANDLES_PER_USER],
['live share handles per app', EVENTS_KV_HANDLES_PER_APP],
];
describe('tiered', () => {
it('gives paid plans the base, and the free and temporary tiers their own', () => {
const limit = tiered(30, 20, 10);
expect(limitFor(limit, null)).toBe(30);
expect(limitFor(limit, 'some_plan')).toBe(30);
expect(limitFor(limit, DEFAULT_FREE_SUBSCRIPTION)).toBe(20);
expect(limitFor(limit, DEFAULT_TEMP_SUBSCRIPTION)).toBe(10);
});
});
describe('the tiered subscription quotas', () => {
it.each(tiers)('%s never lets a free tier exceed paid', (_name, tier) => {
for (const n of Object.values(tier.bySubscription))
@@ -119,9 +105,9 @@ describe('the tiered subscription quotas', () => {
});
it('holds an unrecognised plan to the paid base', () => {
expect(
limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, 'some_plan'),
).toBe(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit);
expect(limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, 'some_plan')).toBe(
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit,
);
expect(limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, null)).toBe(
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit,
);
@@ -145,22 +131,22 @@ describe('the delivery budgets', () => {
expect(
limitFor(EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT, plan),
).toBe(matched);
expect(limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan)).toBe(
evaluated,
);
expect(
limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan),
).toBe(evaluated);
expect(evaluated).toBeGreaterThanOrEqual(matched);
}
});
it('pins the share-handle quotas by plan', () => {
expect(EVENTS_KV_HANDLES_PER_USER.limit).toBe(25_000);
expect(EVENTS_KV_HANDLES_PER_APP.limit).toBe(10_000);
expect(EVENTS_KV_HANDLES_PER_USER.limit).toBe(512);
expect(EVENTS_KV_HANDLES_PER_APP.limit).toBe(512);
expect(
limitFor(EVENTS_KV_HANDLES_PER_USER, DEFAULT_FREE_SUBSCRIPTION),
).toBe(5_000);
).toBe(200);
expect(
limitFor(EVENTS_KV_HANDLES_PER_APP, DEFAULT_FREE_SUBSCRIPTION),
).toBe(2_000);
).toBe(128);
});
it('keeps `single` well under broadcast — each one costs far more', () => {
+27 -103
View File
@@ -53,12 +53,7 @@ export interface TieredLimit {
bySubscription: Record<string, number>;
}
/** Paid, free and temporary caps as a {@link TieredLimit}. Called by extensions. */
export const tiered = (
paid: number,
free: number,
temp: number,
): TieredLimit => ({
const tiered = (paid: number, free: number, temp: number): TieredLimit => ({
limit: paid,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: free,
@@ -98,18 +93,6 @@ export interface TieredQuota {
*/
export const EVENTS_SESSION_SUBSCRIPTIONS_PER_SOCKET = 50;
/**
* Live subscriptions one user may hold through a share handle someone else
* minted for them, across all of their connections.
*
* Those rows sit in the owner's keyspace, where each one costs a filter
* evaluation on the owner's writes. The count also takes in connections that
* dropped and haven't been reaped yet, so the cap sits far above what
* reconnects leave behind. The owner's own rows through the handle don't
* count.
*/
export const EVENTS_SESSION_SUBSCRIPTIONS_PER_KV_HANDLE = 1000;
/**
* Durable subscriptions one account may hold, across every app.
*
@@ -142,13 +125,6 @@ export const EVENTS_DURABLE_SUBSCRIPTIONS_MAX =
*/
export const SUSPENDED_ROW_TTL_DAYS = 30;
/**
* Subscriptions stopped by a revoked grant that one account keeps for `list`.
* They hold no quota slot, so past this the oldest are deleted when the account
* subscribes again, keeping its rows bounded.
*/
export const EVENTS_REVOKED_SUBSCRIPTIONS_KEPT = 500;
/**
* Subscribe calls per minute, per user.
*
@@ -179,13 +155,6 @@ export const EVENTS_LIST_LIMIT = userWindow('events:list', 120);
*/
export const EVENTS_ACK_LIMIT = userWindow('events:ack', 600);
/**
* How long a connected client holds a queued delivery before the events worker
* may take it. A client that has no handler for it, or whose handler fails,
* declines it at once; this only bounds one that never answers.
*/
export const EVENTS_SOCKET_LEASE_MS = 15_000;
/**
* Share-handle mint + revoke calls per minute, per user.
*
@@ -194,68 +163,23 @@ export const EVENTS_SOCKET_LEASE_MS = 15_000;
*/
export const EVENTS_KV_HANDLE_LIMIT = userWindow('events:kvHandles', 60);
/**
* `kv.share.received` notices per hour, per owner and holder. A share past it
* is still made, without a notice.
*/
export const KV_SHARE_NOTICE_LIMIT = userWindow(
'kvShare:notices',
5,
60 * 60_000,
);
/**
* Live share handles one account may hold out at a time, across every app.
*
* A backstop above the per-app cap: every count reads a covering index, so the
* bound is on standing grants, not query cost. A temporary account holds none:
* the grant outlives the session that made it, and no account is left to take
* it back.
* Each is a standing grant on part of the account's data, and revoking marks
* rather than deletes, so without a ceiling the rate limit alone lets the rows
* grow forever. A temporary account holds none: the grant outlives the session
* that made it, and no account is left to take it back.
*/
export const EVENTS_KV_HANDLES_PER_USER = tiered(25_000, 5_000, 0);
export const EVENTS_KV_HANDLES_PER_USER = tiered(512, 200, 0);
/**
* Live share handles one app may hold out for one account, so one namespace
* cannot consume the whole account budget.
* Live share handles one app may hold out for one account.
*
* Separately bounded from the account cap so one namespace cannot consume the
* whole budget on free accounts.
*/
export const EVENTS_KV_HANDLES_PER_APP = tiered(10_000, 2_000, 0);
/**
* Live share handles one owner may hold out to one holder, on every plan.
* Bounds the rows a revoke or unshare walks to find the narrower handles inside
* one.
*/
export const EVENTS_KV_HANDLES_PER_HOLDER = 256;
/**
* Recipients one account may share with, per minute and per hour. A call spends
* one per recipient that doesn't already hold the share, on top of the per-call
* mint budget.
*/
export const KV_SHARE_GRANT_LIMITS = [
userWindow('kvShare:grants:minute', 300),
userWindow('kvShare:grants:hour', 2_000, 60 * 60_000),
] as const;
/**
* Recipients one account may name in share calls per minute, existing shares
* included. Each one is looked up before the call knows whether it changes
* anything, so a client that shares again on every load is bounded here.
*/
export const KV_SHARE_LOOKUP_LIMIT = userWindow('kvShare:lookups', 1_200);
/** Recipients one `POST /kv/shares` call may name. */
export const KV_SHARE_BATCH_MAX = 50;
/**
* Notifications one party of a share may send the other through share handles,
* per minute and per day, in one app. Keeps one share from spending the
* recipient's whole notification allowance.
*/
export const KV_SHARE_NOTIFY_LIMITS = [
userWindow('kvShare:notify:minute', 30),
userWindow('kvShare:notify:day', 500, 24 * 60 * 60_000),
] as const;
export const EVENTS_KV_HANDLES_PER_APP = tiered(512, 128, 0);
// -- Handler surface -------------------------------------------------
@@ -353,11 +277,8 @@ export const EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT = tiered(512, 128, 128);
*/
export const EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT = tiered(2048, 512, 512);
/**
* Deliveries of one key-value change that carry its value inline, in the order
* the fan-out serves them. Later ones carry the key alone.
*/
export const EVENTS_KV_VALUE_DELIVERIES_PER_EVENT = 128;
/** Omit all inline KV values when the matched audience exceeds this count. */
export const EVENTS_KV_VALUE_OMIT_MATCHED_SUBSCRIPTIONS = 128;
/**
* Largest inline KV value in serialized bytes. Larger values are omitted;
@@ -449,20 +370,16 @@ export const EVENTS_REGION_PENDING_CEILING = 1_000_000;
// a handler that answers "no" is not. Retrying on a fixed cadence turns one
// broken deploy into a permanent load on whatever is failing, so the wait
// doubles per attempt up to a ceiling, and a run of failures stops the
// subscription rather than retrying it forever. A run Puter could not start or
// finish backs off the same way but is not the handler's failure, so it never
// counts toward that run.
// subscription rather than retrying it forever.
// Wait before the first retry of a delivery a handler could not take, the
// longest wait, and the doubling between them. Shared with system jobs.
export {
deliveryBackoffMs,
EVENTS_RETRY_BASE_MS,
EVENTS_RETRY_MAX_MS,
} from '../../util/retryBackoff.js';
/** Wait before the first retry of a delivery a handler could not take. */
export const EVENTS_RETRY_BASE_MS = 2_000;
/** Longest wait between retries, however many have failed. */
export const EVENTS_RETRY_MAX_MS = 5 * 60 * 1000;
/**
* Handler failures in a row before a subscription is suspended. Counted per
* Failures in a row before a subscription is suspended. Counted per
* subscription and reset by the first delivery a handler takes, so an
* occasional failure never accumulates into one.
*/
@@ -475,6 +392,13 @@ export const EVENTS_CONSECUTIVE_FAILURES = 5;
*/
export const EVENTS_FAILURE_COUNTER_TTL_MS = 60 * 60 * 1000;
/** How long to hold a delivery whose handler has failed `attempts` times. */
export const deliveryBackoffMs = (attempts: number): number =>
Math.min(
EVENTS_RETRY_MAX_MS,
EVENTS_RETRY_BASE_MS * 2 ** Math.max(0, attempts - 1),
);
// -- Suspended backlog -----------------------------------------------
//
// A suspended subscription stops metering but keeps what it is owed, and that
@@ -1,274 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* The schedule routes are thin delegates: without `services.schedules` every
* one answers 503 `events_disabled`; with it, each hands its request over and
* answers what it returns.
*/
import type { Request, Response } from 'express';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import { makeActor, type Actor } from '../../core/actor.js';
import { isHttpError } from '../../core/http/HttpError.js';
import type {
ScheduleServiceContract,
ScheduleView,
} from '../../core/schedules.js';
import type { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import type { BroadcastController } from '../broadcast/BroadcastController.js';
import type { EventsController } from './EventsController.js';
let server: PuterServer;
let events: EventsController;
let broadcast: BroadcastController;
let actor: Actor;
let saved: unknown;
const SCHEDULE_ID = '0b3a3c9e-9f61-4f0e-8d2a-2f1b6f8f2c11';
const view = {
scheduleId: SCHEDULE_ID,
kind: 'recurring',
appUid: 'app-x',
} as ScheduleView;
const slot = () => server.services as unknown as { schedules?: unknown };
/** Put `schedules` in the slot, or leave the slot empty. */
const setSchedules = (schedules: unknown): void => {
if (schedules === undefined) delete slot().schedules;
else slot().schedules = schedules;
};
const fakeSchedules = () =>
({
onSubscriptionsChanged: vi.fn(async () => {}),
createFor: vi.fn(async () => view),
list: vi.fn(async () => ({
items: [view],
cursor: 'next',
total: 3,
})),
get: vi.fn(async () => view),
remove: vi.fn(async () => {}),
receivePeerChanges: vi.fn(async () => ({ applied: 2 })),
prepareSendAt: vi.fn(),
createDeferredAppMessage: vi.fn(),
}) satisfies ScheduleServiceContract;
const req = (init: {
body?: unknown;
query?: Record<string, unknown>;
params?: Record<string, string>;
}): Request =>
({
actor,
body: init.body ?? {},
query: init.query ?? {},
params: init.params ?? {},
headers: {},
rawBody: Buffer.from(JSON.stringify(init.body ?? {})),
}) as unknown as Request;
const res = () => {
const sent: { status: number; body: unknown } = {
status: 200,
body: undefined,
};
const response = {
json: vi.fn((body: unknown) => {
sent.body = body;
return response;
}),
status: vi.fn((code: number) => {
sent.status = code;
return response;
}),
setHeader: vi.fn(() => response),
};
return { response: response as unknown as Response, sent };
};
/** Each route, called as the router would. */
const routes: Array<[string, (response: Response) => Promise<void>]> = [
[
'POST /events/schedules',
(r) => events.createSchedule(req({ body: { every: 60_000 } }), r),
],
[
'GET /events/schedules',
(r) => events.listSchedules(req({ query: { limit: '5' } }), r),
],
[
'GET /events/schedules/:scheduleId',
(r) =>
events.getSchedule(req({ params: { scheduleId: SCHEDULE_ID } }), r),
],
[
'POST /events/schedules/remove',
(r) =>
events.removeSchedule(
req({ body: { scheduleId: SCHEDULE_ID } }),
r,
),
],
[
'POST /broadcast/events/schedules',
(r) =>
broadcast.schedules(
req({ body: { namespace: 'n', changes: [] } }),
r,
),
],
];
beforeAll(async () => {
server = await setupTestServer();
events = server.controllers.events as unknown as EventsController;
broadcast = server.controllers.broadcast as unknown as BroadcastController;
actor = makeActor({
user: { id: 1, uuid: 'u-1', username: 'someone' },
} as never);
saved = slot().schedules;
vi.spyOn(
server.services.broadcast,
'verifySignedRequest',
).mockResolvedValue({ ok: true, peerId: 'peer' } as never);
});
afterEach(() => {
setSchedules(saved);
});
afterAll(async () => {
vi.restoreAllMocks();
await server?.shutdown();
});
describe('without services.schedules', () => {
it.each(routes)('%s answers 503 events_disabled', async (_name, call) => {
setSchedules(undefined);
const { response } = res();
let thrown: unknown;
try {
await call(response);
} catch (err) {
thrown = err;
}
expect(isHttpError(thrown)).toBe(true);
expect(thrown).toMatchObject({
statusCode: 503,
legacyCode: 'events_disabled',
// The designed answer, so it never pages.
noAlarm: true,
});
});
it('refuses an unsigned peer batch before saying schedules are off', async () => {
setSchedules(undefined);
vi.spyOn(
server.services.broadcast,
'verifySignedRequest',
).mockResolvedValueOnce({
ok: false,
status: 401,
message: 'bad signature',
} as never);
const { response, sent } = res();
await broadcast.schedules(
req({ body: { namespace: 'n', changes: [] } }),
response,
);
expect(sent.status).toBe(401);
});
});
describe('with services.schedules', () => {
it('creates through createFor and answers its view', async () => {
const schedules = fakeSchedules();
setSchedules(schedules);
const { response, sent } = res();
await events.createSchedule(req({ body: { every: 60_000 } }), response);
expect(schedules.createFor).toHaveBeenCalledWith(actor, {
every: 60_000,
});
expect(sent.body).toEqual(view);
});
it('lists with the limit capped, the cursor and includeTotal', async () => {
const schedules = fakeSchedules();
setSchedules(schedules);
const { response, sent } = res();
await events.listSchedules(
req({
query: { limit: '5000', cursor: 'c', includeTotal: 'true' },
}),
response,
);
expect(schedules.list).toHaveBeenCalledWith(actor, {
limit: 200,
cursor: 'c',
includeTotal: true,
});
expect(sent.body).toEqual({ items: [view], cursor: 'next', total: 3 });
});
it('reads one through get', async () => {
const schedules = fakeSchedules();
setSchedules(schedules);
const { response, sent } = res();
await events.getSchedule(
req({ params: { scheduleId: SCHEDULE_ID } }),
response,
);
expect(schedules.get).toHaveBeenCalledWith(actor, SCHEDULE_ID);
expect(sent.body).toEqual(view);
});
it('removes through remove and answers {}', async () => {
const schedules = fakeSchedules();
setSchedules(schedules);
const { response, sent } = res();
await events.removeSchedule(
req({ body: { scheduleId: SCHEDULE_ID } }),
response,
);
expect(schedules.remove).toHaveBeenCalledWith(actor, SCHEDULE_ID);
expect(sent.body).toEqual({});
});
it('hands a signed peer batch to receivePeerChanges', async () => {
const schedules = fakeSchedules();
setSchedules(schedules);
const { response, sent } = res();
const body = { namespace: 'n', changes: [] };
await broadcast.schedules(req({ body }), response);
expect(schedules.receivePeerChanges).toHaveBeenCalledWith(body);
expect(sent).toEqual({ status: 200, body: { ok: true, applied: 2 } });
});
});
@@ -25,13 +25,8 @@
import MockRedis from 'ioredis-mock';
import { describe, expect, it } from 'vitest';
import type { EventsInvokeCall } from '../../clients/events/EventsWorkerInvokerClient.js';
import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
import {
EventsWorkerDeployer,
LocalEventsInvokeTransport,
type EventsDeployOutcome,
} from './workerDeploy.js';
import { EventsWorkerDeployer } from './workerDeploy.js';
import { eventsWorkerScript } from '../../services/events/workerRuntime.js';
import { handlerSetHash } from '../../services/events/workerSource.js';
import type { IConfig } from '../../types.js';
@@ -54,8 +49,6 @@ type Layers = ConstructorParameters<typeof EventsWorkerDeployer>[0];
const makeLayers = (owners: Record<string, Owner | null>) => {
const redis = new MockRedis.Cluster(['redis://localhost:7001']);
const created: string[] = [];
/** Apps whose handler sources were read, once per read. */
const sourceReads: string[] = [];
const layers = {
config: {
events: { workerRuntime: true, internalSecret: 'sekret' },
@@ -67,20 +60,16 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
clients: { redis },
stores: {
eventHandler: {
setForApp: async () => [{ name: 'h', sourceHash: SOURCE_HASH }],
allForApp: async (appUid: string) => {
sourceReads.push(appUid);
return [
{
appUid,
name: 'h',
source: 'async () => {}',
sourceHash: SOURCE_HASH,
createdAt: 0,
updatedAt: 0,
},
];
},
allForApp: async (appUid: string) => [
{
appUid,
name: 'h',
source: 'async () => {}',
sourceHash: SOURCE_HASH,
createdAt: 0,
updatedAt: 0,
},
],
},
app: {
getByUid: async (appUid: string) => {
@@ -115,12 +104,7 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
},
},
};
return {
layers: layers as unknown as Layers,
created,
redis,
sourceReads,
};
return { layers: layers as unknown as Layers, created, redis };
};
describe('EventsWorkerDeployer.ensure', () => {
@@ -143,10 +127,8 @@ describe('EventsWorkerDeployer.ensure', () => {
expect(b).toBe('no-owner');
});
it('refuses once the app has deployed past the hourly cap, before reading or building any source', async () => {
const { layers, redis, sourceReads, created } = makeLayers({
'app-c': { ownerUserId: 3 },
});
it('refuses once the app has deployed past the hourly cap', async () => {
const { layers, redis } = makeLayers({ 'app-c': { ownerUserId: 3 } });
const deployer = new EventsWorkerDeployer(layers);
const hour = new Date().toISOString().slice(0, 13);
await redis.set(
@@ -155,24 +137,6 @@ describe('EventsWorkerDeployer.ensure', () => {
);
expect(await deployer.ensure('app-c', SCRIPT)).toBe('throttled');
expect(sourceReads).toEqual([]);
expect(created).toEqual([]);
});
it('answers a stale script without spending the hourly cap', async () => {
const { layers, redis, sourceReads } = makeLayers({
'app-e': { ownerUserId: 5 },
});
const deployer = new EventsWorkerDeployer(layers);
const stale = eventsWorkerScript(
handlerSetHash([{ name: 'h', sourceHash: 'b'.repeat(64) }]),
'',
);
expect(await deployer.ensure('app-e', stale)).toBe('stale');
expect(sourceReads).toEqual([]);
const hour = new Date().toISOString().slice(0, 13);
expect(await redis.get(`ev:deploys:{app-e}:${hour}`)).toBeNull();
});
it('deploys normally under the cap', async () => {
@@ -181,151 +145,3 @@ describe('EventsWorkerDeployer.ensure', () => {
expect(await deployer.ensure('app-d', SCRIPT)).toBe('deployed');
});
});
/** A server's schedules with a deploy gate that records and can refuse. */
const withGate = (
layers: Layers,
refuse: (capped: boolean) => boolean = () => false,
) => {
const gated: Array<{ script: string; capped: boolean }> = [];
const gate = {
gateDeploy: async (
script: string,
options: { capped: boolean },
deploy: () => Promise<EventsDeployOutcome>,
): Promise<EventsDeployOutcome> => {
gated.push({ script, capped: options.capped });
return refuse(options.capped) ? 'busy' : deploy();
},
};
const deployer = new EventsWorkerDeployer({
...layers,
services: { ...layers.services, schedules: gate },
} as unknown as Layers);
return { deployer, gated };
};
describe('the schedules` deploy gate', () => {
it('is skipped without schedules, or with schedules that have no gate', async () => {
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
await expect(
new EventsWorkerDeployer(layers).ensure('app-x', SCRIPT, {
lane: 'schedule',
}),
).resolves.toBe('deployed');
const gateless = new EventsWorkerDeployer({
...layers,
services: { ...layers.services, schedules: {} },
} as unknown as Layers);
await expect(
gateless.ensure('app-x', SCRIPT, { lane: 'schedule' }),
).resolves.toBe('deployed');
expect(created).toEqual([SCRIPT, SCRIPT]);
});
it('takes every deploy, capped only for a schedule run', async () => {
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
const { deployer, gated } = withGate(layers);
await expect(deployer.ensure('app-x', SCRIPT)).resolves.toBe(
'deployed',
);
await expect(
deployer.ensure('app-x', SCRIPT, { lane: 'schedule' }),
).resolves.toBe('deployed');
expect(gated).toEqual([
{ script: SCRIPT, capped: false },
{ script: SCRIPT, capped: true },
]);
expect(created).toEqual([SCRIPT, SCRIPT]);
});
it('answers a refused schedule run busy, and a delivery still deploys', async () => {
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
const { deployer } = withGate(layers, (capped) => capped);
await expect(
deployer.ensure('app-x', SCRIPT, { lane: 'schedule' }),
).resolves.toBe('busy');
await expect(deployer.ensure('app-x', SCRIPT)).resolves.toBe(
'deployed',
);
expect(created).toEqual([SCRIPT]);
});
it('lets deliveries that join a schedule run`s refused deploy deploy once', async () => {
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
const { deployer, gated } = withGate(layers, (capped) => capped);
const scheduled = deployer.ensure('app-x', SCRIPT, {
lane: 'schedule',
});
const deliveries = [
deployer.ensure('app-x', SCRIPT),
deployer.ensure('app-x', SCRIPT),
];
await expect(scheduled).resolves.toBe('busy');
await expect(Promise.all(deliveries)).resolves.toEqual([
'deployed',
'deployed',
]);
expect(created).toEqual([SCRIPT]);
expect(gated.map((g) => g.capped)).toEqual([true, false]);
});
it('lets a schedule run join a delivery`s deploy in flight', async () => {
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
const { deployer, gated } = withGate(layers, (capped) => capped);
const delivery = deployer.ensure('app-x', SCRIPT);
const scheduled = deployer.ensure('app-x', SCRIPT, {
lane: 'schedule',
});
await expect(Promise.all([delivery, scheduled])).resolves.toEqual([
'deployed',
'deployed',
]);
expect(gated).toEqual([{ script: SCRIPT, capped: false }]);
expect(created).toEqual([SCRIPT]);
});
});
describe('LocalEventsInvokeTransport', () => {
it('renders each request as it goes out, after a cold deploy', async () => {
const bodies: string[] = [];
let resident = false;
const transport = new LocalEventsInvokeTransport(
{
localworkerservice: {
dispatchEventsWorker: async (request: Request) => {
bodies.push(await request.text());
if (!resident) return null;
return new Response(null, {
status: 200,
headers: { 'x-puter-events-handled': '1' },
});
},
},
} as never,
{
ensure: async () => {
resident = true;
return 'deployed';
},
} as unknown as EventsWorkerDeployer,
);
let renders = 0;
const call: EventsInvokeCall = {
script: SCRIPT,
appUid: 'app-x',
key: 'k',
body: '{"n":0}',
timeoutMs: 5000,
render: () => `{"n":${++renders}}`,
};
await expect(transport.send(call)).resolves.toMatchObject({
status: 200,
});
expect(bodies).toEqual(['{"n":1}', '{"n":2}']);
});
});
+41 -132
View File
@@ -26,27 +26,18 @@ import {
INTERNAL_ADMISSION_BYPASS,
INTERNAL_DEPLOY_TARGET,
} from '../../drivers/workers/WorkerDriver.js';
import type {
EventsInvokeAnswer,
EventsInvokeCall,
InvokeLane,
} from '../../clients/events/EventsWorkerInvokerClient.js';
import type { EventsInvokeCall } from '../../clients/events/EventsWorkerInvokerClient.js';
import {
EVENTS_HANDLED_HEADER,
EVENTS_INVOKE_PATH,
answerFrom,
failedAnswer,
} from '../../clients/events/EventsWorkerInvokerClient.js';
import type { puterServices } from '../../services/index.js';
import type { IExtensionServiceInstances } from '../../services/types.js';
import {
eventsInvokeKey,
eventsWorkerScope,
eventsWorkerScript,
} from '../../services/events/workerRuntime.js';
import {
generateEventsWorkerSource,
handlerSetHash,
} from '../../services/events/workerSource.js';
import { generateEventsWorkerSource } from '../../services/events/workerSource.js';
import type { puterStores } from '../../stores/index.js';
import type { IConfig, LayerInstances } from '../../types.js';
import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
@@ -63,25 +54,13 @@ import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
/** Why a deploy could not happen, for the callers that answer differently. */
export type EventsDeployOutcome =
| 'deployed'
| 'stale'
| 'no-handlers'
| 'no-owner'
| 'throttled'
| 'failed'
| 'busy';
/** Whose call asked for the deploy: a schedule run's is held to a share. */
export interface EventsDeployOptions {
lane?: InvokeLane;
}
'deployed' | 'stale' | 'no-handlers' | 'no-owner' | 'throttled' | 'failed';
interface DeployLayers {
config: IConfig;
clients: LayerInstances<typeof puterClients>;
stores: LayerInstances<typeof puterStores>;
services: LayerInstances<typeof puterServices> &
Pick<IExtensionServiceInstances, 'schedules'>;
services: LayerInstances<typeof puterServices>;
drivers: LayerInstances<typeof puterDrivers>;
}
@@ -91,12 +70,6 @@ interface DeployLayers {
*/
const DEPLOY_THROTTLE_TTL_SECONDS = 2 * 60 * 60;
interface InFlightDeploy {
/** A schedule run's, which the region's deploy slots may refuse. */
capped: boolean;
promise: Promise<EventsDeployOutcome>;
}
interface DeployResult {
success?: boolean;
errors?: unknown[];
@@ -105,7 +78,7 @@ interface DeployResult {
export class EventsWorkerDeployer {
readonly #layers: DeployLayers;
/** Collapses the burst of misses one cold app produces into one deploy. */
readonly #inFlight = new Map<string, InFlightDeploy>();
readonly #inFlight = new Map<string, Promise<EventsDeployOutcome>>();
constructor(layers: DeployLayers) {
this.#layers = layers;
@@ -124,79 +97,15 @@ export class EventsWorkerDeployer {
* A request for anything else is `stale` — the set moved on, and the next
* delivery resolves the new script rather than this one being revived.
*/
ensure(
appUid: string,
script: string,
options: EventsDeployOptions = {},
): Promise<EventsDeployOutcome> {
ensure(appUid: string, script: string): Promise<EventsDeployOutcome> {
const key = `${appUid}|${script}`;
const capped = options.lane === 'schedule';
const existing = this.#inFlight.get(key);
if (!existing) return this.#start(key, appUid, script, options);
if (capped || !existing.capped) return existing.promise;
return this.#afterCapped(
existing.promise,
key,
appUid,
script,
options,
);
}
#start(
key: string,
appUid: string,
script: string,
options: EventsDeployOptions,
): Promise<EventsDeployOutcome> {
const entry: InFlightDeploy = {
capped: options.lane === 'schedule',
promise: this.#gated(appUid, script, options).finally(() => {
if (this.#inFlight.get(key) === entry)
this.#inFlight.delete(key);
}),
};
this.#inFlight.set(key, entry);
return entry.promise;
}
/**
* A delivery that joined a schedule run's deploy: `busy` means no deploy
* started, and a delivery is never refused a slot, so it asks again.
*/
async #afterCapped(
capped: Promise<EventsDeployOutcome>,
key: string,
appUid: string,
script: string,
options: EventsDeployOptions,
): Promise<EventsDeployOutcome> {
const outcome = await capped;
if (outcome !== 'busy') return outcome;
const current = this.#inFlight.get(key);
if (current && !current.capped) return current.promise;
return this.#start(key, appUid, script, options);
}
/**
* Every deploy goes through the schedules' deploy gate when a server has
* one: a per-script lock, so a burst of runs of an evicted script deploys
* it once, and a region-wide cap that a schedule run's deploy (`capped`)
* may be refused with `busy`. A delivery's deploy is never refused.
*/
#gated(
appUid: string,
script: string,
options: EventsDeployOptions,
): Promise<EventsDeployOutcome> {
const deploy = () => this.#deploy(appUid, script);
return (
this.#layers.services.schedules?.gateDeploy?.(
script,
{ capped: options.lane === 'schedule' },
deploy,
) ?? deploy()
if (existing) return existing;
const started = this.#deploy(appUid, script).finally(() =>
this.#inFlight.delete(key),
);
this.#inFlight.set(key, started);
return started;
}
async #deploy(
@@ -204,24 +113,20 @@ export class EventsWorkerDeployer {
script: string,
): Promise<EventsDeployOutcome> {
const { stores, drivers } = this.#layers;
const scope = eventsWorkerScope(this.#layers.config);
// Names and hashes are enough to tell a stale request, so the set's
// sources are read and built only once the throttle has let it through.
const set = await stores.eventHandler.setForApp(appUid);
if (set.length === 0) return 'no-handlers';
const handlers = await stores.eventHandler.allForApp(appUid);
if (handlers.length === 0) return 'no-handlers';
const generated = generateEventsWorkerSource(handlers);
// Two apps that published byte-identical sets share one script, which
// is sound: the source is the same, and everything per-app arrives with
// the invocation. Whoever deploys first owns it.
if (eventsWorkerScript(handlerSetHash(set), scope) !== script)
return 'stale';
if (await this.#tooManyDeploysThisHour(appUid)) return 'throttled';
const handlers = await stores.eventHandler.allForApp(appUid);
const generated = generateEventsWorkerSource(handlers);
// The set can move between the two reads.
if (eventsWorkerScript(generated.setHash, scope) !== script)
if (
eventsWorkerScript(
generated.setHash,
eventsWorkerScope(this.#layers.config),
) !== script
)
return 'stale';
if (generated.tooLarge) {
console.error(
@@ -235,6 +140,8 @@ export class EventsWorkerDeployer {
generated.broken.join(', '),
);
if (await this.#tooManyDeploysThisHour(appUid)) return 'throttled';
const owner = await this.#owner(appUid);
if (!owner) return 'no-owner';
@@ -382,9 +289,11 @@ export class LocalEventsInvokeTransport {
this.#deployer = deployer;
}
async send(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
// Built per attempt, so a send time stamped into the body is taken
// after a cold deploy rather than before it.
async send(call: EventsInvokeCall): Promise<{
status: number | null;
handled?: boolean;
error?: string;
}> {
const request = (): Request =>
new Request(
`http://${call.script}.workers.puter.localhost${EVENTS_INVOKE_PATH}`,
@@ -394,7 +303,7 @@ export class LocalEventsInvokeTransport {
'content-type': 'application/json',
'x-puter-events-key': call.key,
},
body: call.render ? call.render() : call.body,
body: call.body,
},
);
@@ -405,21 +314,19 @@ export class LocalEventsInvokeTransport {
const outcome = await this.#deployer.ensure(
call.appUid,
call.script,
{ lane: call.lane },
);
if (outcome !== 'deployed')
return {
status: null,
error: `deploy: ${outcome}`,
...(outcome === 'busy' ? { deployBusy: true } : {}),
};
return { status: null, error: `deploy: ${outcome}` };
const retried = await this.#dispatch(request(), call.timeoutMs);
if (retried === null)
return { status: null, error: 'script not resident' };
return retried;
} catch (err) {
return failedAnswer(err);
return {
status: null,
error: err instanceof Error ? err.message : String(err),
};
}
}
@@ -430,7 +337,7 @@ export class LocalEventsInvokeTransport {
async #dispatch(
request: Request,
timeoutMs: number,
): Promise<EventsInvokeAnswer | null> {
): Promise<{ status: number; handled: boolean } | null> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<'timeout'>((resolve) => {
timer = setTimeout(() => resolve('timeout'), timeoutMs);
@@ -441,10 +348,12 @@ export class LocalEventsInvokeTransport {
this.#services.localworkerservice.dispatchEventsWorker(request),
timeout,
]);
if (response === 'timeout')
throw new DOMException('invocation timed out', 'TimeoutError');
if (response === 'timeout') throw new Error('invocation timed out');
if (response === null) return null;
return answerFrom(response);
return {
status: response.status,
handled: response.headers.get(EVENTS_HANDLED_HEADER) === '1',
};
} finally {
clearTimeout(timer);
}
@@ -1,80 +0,0 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
describe('recursive readdir with offset', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const post = async (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),
});
it('skips entries before applying the recursive page limit', async () => {
const { username, token } = env.users.user;
const base = `/${username}/Documents/rec-offset`;
for (const path of [base, `${base}/a`, `${base}/b`, `${base}/c`]) {
expect((await post('/fs/mkdir', token, { path })).status).toBe(200);
}
const list = async (body: Record<string, unknown>) => {
const response = await post('/fs/readdir', token, {
path: base,
recursive: true,
...body,
});
expect(response.status).toBe(200);
const page = (await response.json()) as {
items: Array<{ name: string }>;
};
return page.items.map((item) => item.name);
};
expect(await list({ limit: 3 })).toEqual(['a', 'b', 'c']);
// Non-recursive honours offset.
const flat = await post('/fs/readdir', token, {
path: base,
limit: 1,
offset: 2,
});
expect(
((await flat.json()) as Array<{ name: string }>).map((e) => e.name),
).toEqual(['c']);
expect(await list({ limit: 1, offset: 2 })).toEqual(['c']);
expect(await list({ offset: 2 })).toEqual(['c']);
expect(await list({ offset: 20 })).toEqual([]);
expect(await list({ limit: 1, offset: 1, sortOrder: 'desc' })).toEqual([
'b',
]);
const first = await post('/fs/readdir', token, {
path: base,
recursive: true,
limit: 1,
offset: 1,
});
const page = await first.json();
expect(page.items.map((item: { name: string }) => item.name)).toEqual([
'b',
]);
expect(page.cursor).toBeTypeOf('string');
expect(await list({ limit: 1, cursor: page.cursor })).toEqual(['c']);
// A cursor wins over offset, and an invalid offset is ignored, as
// recursive listings did before they honoured offset.
expect(
await list({ limit: 1, cursor: page.cursor, offset: 1 }),
).toEqual(['c']);
expect(await list({ limit: 1, offset: -1 })).toEqual(['a']);
});
});
@@ -1948,27 +1948,6 @@ describe('FSController.readEntry (file streaming)', () => {
expect(captured.statusCode).toBe(206);
});
it('returns 206 with Range honored when offset and byte_count query params are supplied', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const body = Buffer.from('abcdefghij');
const target = `/${username}/Documents/ranged-query.bin`;
await writeFile(userId, target, body, 'application/octet-stream');
const { res, captured } = makeStreamingRes();
await withActor(actor, () =>
controller.readEntry(
makeReq({
query: { path: target, offset: '2', byte_count: '4' },
actor,
}),
res,
),
);
expect(captured.statusCode).toBe(206);
expect(Buffer.concat(captured.bodyChunks).toString()).toBe('cdef');
});
it('throws 404 when the path does not exist', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
+4 -3
View File
@@ -83,7 +83,6 @@ import {
loadLegacyAssociatedApps,
replacedByMove,
resolveOrNotFound,
resolveReadRange,
signEntryThumbnail,
} from './legacyFsHelpers.js';
import type {
@@ -1335,7 +1334,6 @@ export class FSController extends PuterController {
parent.path,
{
limit,
offset,
cursor:
typeof body.cursor === 'string'
? body.cursor
@@ -1497,7 +1495,10 @@ export class FSController extends PuterController {
);
}
const range = resolveReadRange(req.headers.range, query);
const range =
typeof req.headers.range === 'string'
? req.headers.range
: undefined;
const download = await this.services.fs.readContent(entry, {
range,
});
@@ -1,114 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { makeActor } from '../../core/actor.js';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
describe('/auth/check-app-acl', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const post = (path: string, body: unknown, token: string) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const makeDirAndApp = async () => {
const { username, token } = env.users.user;
const server = env.server;
const user = (await server.stores.user.getByUsername(username))!;
const path = `/${username}/Documents/cacl-${uuidv4()}`;
expect((await post('/fs/mkdir', { path }, token)).status).toBe(200);
const entry = (await server.stores.fsEntry.getEntryByPath(path))!;
const app = await (
server.stores.app.create as unknown as (
f: Record<string, unknown>,
o: { ownerUserId: number },
) => Promise<{ uid: string; id: number }>
)(
{
name: `cacl-${uuidv4()}`,
title: 'Check app acl',
index_url: `https://cacl-${uuidv4()}.test/`,
},
{ ownerUserId: user.id },
);
return { user, path, entry, app };
};
it('allows a granted file without poisoning the app permission cache', async () => {
const { token } = env.users.user;
const server = env.server;
const { user, path, entry, app } = await makeDirAndApp();
const grant = await post(
'/auth/grant-user-app',
{ app_uid: app.uid, permission: `fs:${entry.uuid}:read` },
token,
);
expect(grant.status).toBe(200);
const res = await post(
'/auth/check-app-acl',
{ subject: { path }, app: app.uid, mode: 'read' },
token,
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ allowed: true });
const asApp = makeActor({ user, app: { uid: app.uid, id: app.id } });
const descriptor = () => server.services.acl.fsDescriptor(path);
expect(
await server.services.acl.check(asApp, descriptor(), 'read'),
).toBe(true);
});
it('denies an app with no grant', async () => {
const { path, app } = await makeDirAndApp();
const res = await post(
'/auth/check-app-acl',
{ subject: { path }, app: app.uid, mode: 'read' },
env.users.user.token,
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ allowed: false });
});
it('refuses app and access tokens', async () => {
const { token, apiToken } = env.users.user;
const { path, entry, app } = await makeDirAndApp();
const grant = await post(
'/auth/grant-user-app',
{ app_uid: app.uid, permission: `fs:${entry.uuid}:read` },
token,
);
expect(grant.status).toBe(200);
const minted = await post(
'/auth/get-user-app-token',
{ app_uid: app.uid },
token,
);
expect(minted.status).toBe(200);
const appToken = ((await minted.json()) as { token: string }).token;
for (const credential of [appToken, apiToken]) {
const res = await post(
'/auth/check-app-acl',
{ subject: { path }, app: app.uid, mode: 'read' },
credential,
);
expect(res.status).toBe(403);
}
});
});
@@ -1,204 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const post = (path: string, body: unknown) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.users.user.token}`,
},
body: JSON.stringify(body),
});
const write = (path: string, content: string) =>
post('/fs/write', {
fileMetadata: { path, size: content.length, contentType: 'text/plain' },
fileContent: content,
});
const read = async (path: string) => {
const url = new URL('/read', env.apiOrigin);
url.searchParams.set('file', path);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${env.users.user.token}` },
});
return response.text();
};
const makeDir = async (name: string) => {
const path = `/${env.users.user.username}/Documents/${name}-${randomUUID().slice(0, 8)}`;
expect((await post('/fs/mkdir', { path })).status).toBe(200);
return path;
};
describe('legacy /copy onto an existing file', () => {
it('replaces the file with overwrite', async () => {
const dir = await makeDir('copy-onto');
expect((await write(`${dir}/a.txt`, 'aaa')).status).toBe(200);
expect((await write(`${dir}/b.txt`, 'bbbbbb')).status).toBe(200);
const copy = await post('/copy', {
source: `${dir}/a.txt`,
destination: `${dir}/b.txt`,
overwrite: true,
});
expect(copy.status).toBe(200);
const [result] = (await copy.json()) as Array<{
copied: { name: string; path: string };
overwritten?: { name: string };
}>;
expect(result!.copied.name).toBe('b.txt');
expect(result!.overwritten?.name).toBe('b.txt');
expect(await read(`${dir}/b.txt`)).toBe('aaa');
expect(await read(`${dir}/a.txt`)).toBe('aaa');
});
it('refuses without overwrite and leaves the file alone', async () => {
const dir = await makeDir('copy-onto-refused');
await write(`${dir}/a.txt`, 'aaa');
await write(`${dir}/b.txt`, 'bbbbbb');
const copy = await post('/copy', {
source: `${dir}/a.txt`,
destination: `${dir}/b.txt`,
});
expect(copy.status).toBe(409);
expect(((await copy.json()) as { code: string }).code).toBe(
'item_with_same_name_exists',
);
expect(await read(`${dir}/b.txt`)).toBe('bbbbbb');
});
it('still copies into a directory destination under the same name', async () => {
const dir = await makeDir('copy-into');
await write(`${dir}/a.txt`, 'aaa');
expect((await post('/fs/mkdir', { path: `${dir}/sub` })).status).toBe(
200,
);
const copy = await post('/copy', {
source: `${dir}/a.txt`,
destination: `${dir}/sub`,
});
expect(copy.status).toBe(200);
expect(await read(`${dir}/sub/a.txt`)).toBe('aaa');
});
it('refuses to overwrite a file with itself', async () => {
const dir = await makeDir('copy-self');
await write(`${dir}/a.txt`, 'aaa');
for (const destination of [`${dir}/a.txt`, dir]) {
const copy = await post('/copy', {
source: `${dir}/a.txt`,
destination,
overwrite: true,
});
expect(copy.status).toBe(400);
}
expect(await read(`${dir}/a.txt`)).toBe('aaa');
});
});
describe('signed folder listing', () => {
const signedListing = async (path: string) => {
const signed = await post('/sign', {
items: [{ path, action: 'read' }],
});
expect(signed.status).toBe(200);
const readUrl = new URL(
(await signed.json()).signatures[0].read_url as string,
);
return fetch(
new URL(`${readUrl.pathname}${readUrl.search}`, env.apiOrigin),
);
};
it('lists every child of a folder larger than one query', async () => {
const dir = await makeDir('big');
const parent = (await env.server.stores.fsEntry.getEntryByPath(dir))!;
const total = 10_050;
const now = Math.floor(Date.now() / 1000);
for (let start = 0; start < total; start += 500) {
const rows: unknown[][] = [];
for (let i = start; i < Math.min(total, start + 500); i++) {
const name = `f${String(i).padStart(5, '0')}.txt`;
rows.push([
randomUUID(),
parent.userId,
parent.id,
parent.uuid,
name,
`${dir}/${name}`,
0,
now,
now,
now,
0,
]);
}
await env.server.clients.db.write(
`INSERT INTO fsentries (uuid, user_id, parent_id, parent_uid, name, path, is_dir, modified, created, accessed, size) VALUES ${rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ')}`,
rows.flat(),
);
}
const response = await signedListing(dir);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toMatch(
/^application\/json/,
);
const children = (await response.json()) as Array<{
fsentry_name: string;
write_url?: string;
}>;
expect(children).toHaveLength(total);
expect(children[0]!.fsentry_name).toBe('f00000.txt');
expect(children.at(-1)!.fsentry_name).toBe('f10049.txt');
expect(new Set(children.map((c) => c.fsentry_name)).size).toBe(total);
expect(children.some((c) => c.write_url)).toBe(false);
}, 120_000);
it('answers a small folder in one body', async () => {
const dir = await makeDir('small');
await write(`${dir}/one.txt`, '1');
const response = await signedListing(dir);
expect(response.status).toBe(200);
expect(response.headers.get('content-length')).not.toBeNull();
const children = (await response.json()) as Array<{
fsentry_name: string;
}>;
expect(children.map((c) => c.fsentry_name)).toEqual(['one.txt']);
});
});
@@ -153,34 +153,6 @@ describe('legacy file downloads over HTTP', () => {
},
);
it.each(routes)(
'GET %s answers a ranged read of an empty file with an empty 200',
async (route) => {
const file = await writeFile(
`empty-${route.replace(/\W/g, '')}.bin`,
Buffer.alloc(0),
);
const ranged = await requestFor(route, file, {
headers: { Range: 'bytes=0-511' },
});
expect(ranged.status).toBe(200);
expect(await ranged.text()).toBe('');
},
);
it('GET /read answers byte_count on an empty file with an empty 200', async () => {
const file = await writeFile('empty-byte-count.bin', Buffer.alloc(0));
for (const query of ['byte_count=512', 'offset=0']) {
const url = new URL(`/read?${query}`, env.apiOrigin);
url.searchParams.set('file', file.path);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${env.users.user.token}` },
});
expect(response.status).toBe(200);
expect(await response.text()).toBe('');
}
});
// Storage hands over one chunk and then goes quiet, as a large object
// does while the client is still reading.
const stallReadsOf = (objectKey: string) => {
@@ -1,160 +0,0 @@
import type { Request, RequestHandler, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import type { LegacyFSController } from './LegacyFSController.js';
const PNG_2X2 =
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEUlEQVR4nGP4z8DwH4QZYAwAR8oH+WdZbrcAAAAASUVORK5CYII=';
let server: PuterServer;
let handler: RequestHandler;
type Driver = {
create: (a: Record<string, unknown>) => Promise<Record<string, unknown>>;
};
beforeAll(async () => {
server = await setupTestServer({
api_base_url: 'http://api.test.local',
static_hosting_domain: 'site.test',
} as unknown as Parameters<typeof setupTestServer>[0]);
await server.services.appIcon.ensureIconsDirectory();
const controller = server.controllers
.legacyFs as unknown as LegacyFSController;
const router = new PuterRouter();
controller.registerRoutes(router);
handler = router.routes.find(
(r) =>
r.path === '/get-launch-apps' &&
String(r.method).toLowerCase() === 'get',
)!.handler as RequestHandler;
}, 60_000);
afterAll(async () => {
await server?.shutdown();
});
const makeActor = async (): Promise<{ actor: Actor; id: number }> => {
const username = `v13-${Math.random().toString(36).slice(2, 10)}`;
const u = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
const r = (await server.stores.user.getById(u.id))!;
return {
id: r.id,
actor: {
user: {
id: r.id,
uuid: r.uuid,
username: r.username,
email: r.email ?? null,
email_confirmed: true,
},
} as unknown as Actor,
};
};
const createViaDriver = async (actor: Actor, icon: string) =>
runWithContext({ actor }, () =>
(server.drivers.apps as unknown as Driver).create({
object: {
name: `v13-${Math.random().toString(36).slice(2, 10)}`,
title: 'V',
index_url: `https://v13-${Math.random().toString(36).slice(2, 10)}.test/`,
icon,
},
}),
);
const launchApps = async (actor: Actor, appUid: string, userId: number) => {
await server.clients.db.write(
'INSERT INTO `app_opens` (`app_uid`, `user_id`, `ts`) VALUES (?, ?, ?)',
[appUid, userId, Math.floor(Date.now() / 1000)],
);
let body: { recent: Array<Record<string, unknown>> } | undefined;
const res = {
json: (v: unknown) => {
body = v as typeof body;
return res;
},
status: () => res,
setHeader: () => res,
} as unknown as Response;
await handler(
{
query: { icon_size: '64' },
headers: {},
body: {},
actor,
} as unknown as Request,
res,
() => {},
);
return body!.recent.find((e) => e.uuid === appUid)!;
};
describe('/get-launch-apps recent icon, verified', () => {
it('a valid data-URL icon written through the driver is migrated to a short URL', async () => {
const { actor, id } = await makeActor();
const app = await createViaDriver(
actor,
`data:image/png;base64,${PNG_2X2}`,
);
await vi.waitFor(
async () => {
const row = await server.stores.app.getByUid(app.uid as string);
expect(String(row?.icon).slice(0, 80)).toMatch(
/^https?:\/\/.*\/app-icon\/app-/,
);
},
{ timeout: 10_000 },
);
const entry = await launchApps(actor, app.uid as string, id);
expect(String(entry.icon).length).toBeLessThan(200);
expect(entry.iconCdnUrl).toMatch(/-64\.png$/);
}, 30_000);
it('returns an icon endpoint when migration cannot decode the stored icon', async () => {
const { actor, id } = await makeActor();
// PNG signature passes the write-path sniff; sharp rejects the rest.
const corrupt = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.alloc(1024 * 1024, 0x41),
]).toString('base64');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
let app: Record<string, unknown>;
try {
app = await createViaDriver(
actor,
`data:image/png;base64,${corrupt}`,
);
// Give the background pipeline time to try and fail.
await vi.waitFor(
() =>
expect(
warn.mock.calls.some(
([m]) => m === '[app-icon] icon processing failed',
),
).toBe(true),
{ timeout: 10_000 },
);
} finally {
warn.mockRestore();
}
const row = await server.stores.app.getByUid(app!.uid as string);
expect(String(row?.icon).startsWith('data:image/png')).toBe(true);
const entry = await launchApps(actor, app!.uid as string, id);
expect(entry.icon).toBe(
`http://api.test.local/app-icon/${app!.uid}/64`,
);
}, 30_000);
});
@@ -3732,63 +3732,6 @@ describe('LegacyFSController.read file streaming', () => {
);
expect(captured.headers['Content-Type']).toMatch(/text\/html/);
});
it('honors offset and byte_count query params on /read', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const body = Buffer.from('0123456789abcdef');
const target = `/${username}/Documents/range-test.txt`;
await server.services.fs.write(userId, {
fileMetadata: {
path: target,
size: body.byteLength,
contentType: 'text/plain',
},
fileContent: body,
});
const { res, captured } = makeStreamingRes();
await withActor(actor, () =>
controller.read(
makeReq({
query: { file: target, offset: '4', byte_count: '6' },
actor,
}),
res,
),
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(captured.statusCode).toBe(206);
expect(Buffer.concat(captured.bodyChunks).toString()).toBe('456789');
});
it('rejects invalid offset query param with 400', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const body = Buffer.from('test');
const target = `/${username}/Documents/invalid-offset.txt`;
await server.services.fs.write(userId, {
fileMetadata: {
path: target,
size: body.byteLength,
contentType: 'text/plain',
},
fileContent: body,
});
const { res } = makeStreamingRes();
await expect(
withActor(actor, () =>
controller.read(
makeReq({
query: { file: target, offset: '-1' },
actor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
// ── /file directory listing ─────────────────────────────────────────
@@ -5293,39 +5236,4 @@ describe('LegacyFSController GET /get-launch-apps', () => {
expect(returned).toEqual(recentUids);
});
it('skips the recent opens for recent=0 and keeps recommended', async () => {
const { actor, userId } = await makeUser();
const app = await makeHostedApp(userId, 'unregistered-skip');
await recordOpen(userId, app.uid);
const call = async (query: Record<string, unknown>) => {
const { res, captured } = makeRes();
await withActor(actor, async () => {
await getLaunchAppsHandler()(
makeReq({ actor, query }),
res,
() => {
throw new Error('handler called next() unexpectedly');
},
);
});
return captured.body as {
recommended: unknown[];
recent: unknown[];
};
};
const full = await call({});
expect(full.recent.length).toBeGreaterThan(0);
const opens = vi.spyOn(server.stores.app, 'getRecentAppOpens');
try {
const skipped = await call({ recent: '0' });
expect(skipped.recent).toEqual([]);
expect(skipped.recommended).toEqual(full.recommended);
expect(opens).not.toHaveBeenCalled();
} finally {
opens.mockRestore();
}
});
});
@@ -17,7 +17,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Team2faLookup } from '../../core/http/middleware/gates.js';
import Busboy from 'busboy';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { contentType as contentTypeFromMime } from 'mime-types';
@@ -26,7 +25,6 @@ import {
assertResolvedActor,
isAccessTokenActor,
isAppActor,
isPlainUserActor,
makeActor,
} from '../../core/actor.js';
import { Context } from '../../core/context.js';
@@ -96,7 +94,6 @@ import {
assertAccess,
assertCanCreate,
assertCanMoveInto,
contextBoundStream,
emitGuiItemEvent,
expandClientPath,
findOverwriteTarget,
@@ -104,9 +101,7 @@ import {
getString,
type GuiItemEventName,
loadLegacyAssociatedApps,
rangeForEntry,
replacedByMove,
resolveReadRange,
resolveV1Selector,
signEntry,
signingConfigFromAppConfig,
@@ -384,9 +379,7 @@ export class LegacyFSController extends PuterController {
let recent: unknown[] = [];
const userId = req.actor?.user?.id;
// `recent=0`: a caller that only shows `recommended`.
const wantsRecent = req.query.recent !== '0';
if (userId && wantsRecent) {
if (userId) {
const recentUids = await this.stores.app.getRecentAppOpens(
userId,
{ limit: 10 },
@@ -423,9 +416,9 @@ export class LegacyFSController extends PuterController {
return {
...toAppSummary(app, {
config: this.config,
apiBaseUrl: this.config.api_base_url,
iconSize,
}),
icon: app.icon ?? null,
index_url: backingGone ? null : app.index_url,
...(backingGone
? { privateAccess: buildHostedBackingDenial() }
@@ -806,23 +799,10 @@ export class LegacyFSController extends PuterController {
this.stores.fsEntry,
body.source,
);
const destination = await resolveV1Selector(
const destinationParent = await resolveV1Selector(
this.stores.fsEntry,
body.destination,
);
// A file destination names the copy itself, replaced only with
// `overwrite`.
let newName = getString(body, 'new_name');
let destinationParent = destination;
if (!destination.isDir && !newName && destination.parentUid) {
const parent = await this.stores.fsEntry.getEntryByUuid(
destination.parentUid,
);
if (parent) {
destinationParent = parent;
newName = destination.name;
}
}
await assertAccess(
this.services.acl,
@@ -847,14 +827,14 @@ export class LegacyFSController extends PuterController {
? await findOverwriteTarget(
this.stores.fsEntry,
destinationParent,
newName ?? source.name,
getString(body, 'new_name') ?? source.name,
)
: null;
const copy = await this.services.fs.copy(userId, {
source,
destinationParent,
newName,
newName: getString(body, 'new_name'),
overwrite: overwriteRequested,
dedupeName: getBoolean(body, 'dedupe_name', 'change_name') ?? false,
});
@@ -1307,10 +1287,10 @@ export class LegacyFSController extends PuterController {
});
}
const range = rangeForEntry(
entry,
resolveReadRange(req.headers.range, query),
);
const range =
typeof req.headers.range === 'string'
? req.headers.range
: undefined;
const download = await this.services.fs.readContent(entry, {
range,
});
@@ -1368,7 +1348,7 @@ export class LegacyFSController extends PuterController {
// that guard every other authenticated FS route have to run here.
assertNotSuspended(actor!.user);
assertVerifiedAccount(actor!.user);
await assertTeam2fa(actor!.user, this.stores.team as Team2faLookup);
await assertTeam2fa(actor!.user, this.stores.team);
req.actor = assertResolvedActor(actor!);
Context.set('actor', actor);
@@ -1811,50 +1791,22 @@ export class LegacyFSController extends PuterController {
// owner, so the layout it reveals is theirs to reveal, and the child
// signatures expire; the paths are the only part that outlives them.
if (entry.isDir) {
const signChild = (child: FSEntry) => {
const children = await this.services.fs.listDirectory(entry.uuid);
const signedChildren = children.map((child) => {
const { write_url: _, ...rest } = signEntry(child, signingCfg, {
ttlSeconds: NON_OWNER_SIGNATURE_TTL_SECONDS,
});
return { ...rest, path: maskEntryPath(child) };
};
const pages = this.services.fs.listDirectoryPages(entry.uuid);
const first = await pages.next();
const firstPage = first.done ? [] : first.value;
const second = await pages.next();
if (second.done) {
res.json(firstPage.map(signChild));
return;
}
// Past one page the listing streams, so a large folder isn't held
// in memory whole. A failure partway cuts the response off.
const secondPage = second.value;
const serialize = (child: FSEntry) =>
JSON.stringify(signChild(child));
async function* allPages() {
yield firstPage;
yield secondPage;
yield* pages;
}
async function* body() {
let separator = '[';
for await (const page of allPages()) {
if (page.length === 0) continue;
yield separator + page.map(serialize).join(',');
separator = ',';
}
yield separator === '[' ? '[]' : ']';
}
res.type('application/json');
await pipeToResponse(contextBoundStream(body()), res);
});
res.json(signedChildren);
return;
}
// File: stream bytes with Range support.
const range = rangeForEntry(
entry,
resolveReadRange(req.headers.range, query),
);
const range =
typeof req.headers.range === 'string'
? req.headers.range
: undefined;
const download = await this.services.fs.readContent(entry, {
range,
});
@@ -2044,11 +1996,6 @@ export class LegacyFSController extends PuterController {
*/
checkAppAcl = async (req: Request, res: Response): Promise<void> => {
const actor = this.#requireActor(req);
// Sessions only: for an app, this would be a window onto its neighbours' grants.
if (!isPlainUserActor(actor))
throw new HttpError(403, 'actor must be a user', {
legacyCode: 'forbidden',
});
const body = asRecord(req.body);
const subjectRef = body.subject;
@@ -2079,7 +2026,7 @@ export class LegacyFSController extends PuterController {
legacyCode: 'subject_does_not_exist',
});
let app: { uid: string; id: number } | null = null;
let app: { uid: string } | null = null;
if (typeof appRef === 'string') {
app =
(await this.stores.app.getByUid(appRef)) ??
@@ -2093,7 +2040,7 @@ export class LegacyFSController extends PuterController {
// Build an actor-under-user shape for the check.
const actorForApp = makeActor({
user: actor.user,
app: { uid: app.uid, id: app.id },
app: { uid: (app as { uid: string }).uid },
});
const allowed = await acl.check(actorForApp, descriptor, mode);
res.json({ allowed });
@@ -2141,12 +2088,10 @@ export class LegacyFSController extends PuterController {
// by default, so opt in by naming the downloader as who pays.
req.egressActor = actor;
const range = rangeForEntry(
entry,
const range =
typeof req.headers.range === 'string'
? req.headers.range
: undefined,
);
: undefined;
const download = await this.services.fs.readContent(entry, {
range,
});
@@ -17,20 +17,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Writable } from 'node:stream';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Context, runWithContext } from '../../core/context.js';
import type { PuterServer } from '../../server.js';
import { createTestUser, setupTestServer } from '../../testUtil.js';
import {
asRecord,
contextBoundStream,
fsEntryMimeType,
getBoolean,
getString,
loadLegacyAssociatedApps,
rangeForEntry,
resolveReadRange,
signEntry,
signEntryThumbnail,
signingConfigFromAppConfig,
@@ -623,136 +618,3 @@ describe('toLegacyEntry is_empty against stored children', () => {
expect((await shape()).is_empty).toBe(false);
});
});
describe('resolveReadRange', () => {
it('returns the explicit headersRange when provided', () => {
expect(
resolveReadRange('bytes=10-20', { offset: 0, byte_count: 5 }),
).toBe('bytes=10-20');
});
it('returns undefined when neither headersRange nor range query params are present', () => {
expect(resolveReadRange(undefined, {})).toBeUndefined();
expect(resolveReadRange('', {})).toBeUndefined();
});
it('derives a byte range from offset and byte_count', () => {
expect(resolveReadRange(undefined, { offset: 0, byte_count: 5 })).toBe(
'bytes=0-4',
);
expect(
resolveReadRange(undefined, { offset: 10, byte_count: 20 }),
).toBe('bytes=10-29');
expect(
resolveReadRange(undefined, { offset: '4', byte_count: '6' }),
).toBe('bytes=4-9');
});
it('derives an open range when only offset is provided', () => {
expect(resolveReadRange(undefined, { offset: 10 })).toBe('bytes=10-');
expect(resolveReadRange(undefined, { offset: '0' })).toBe('bytes=0-');
});
it('derives a prefix range when only byte_count is provided', () => {
expect(resolveReadRange(undefined, { byte_count: 15 })).toBe(
'bytes=0-14',
);
expect(resolveReadRange(undefined, { byte_count: '1' })).toBe(
'bytes=0-0',
);
});
it('throws 400 on invalid or negative offset', () => {
expect(() =>
resolveReadRange(undefined, { offset: -1, byte_count: 10 }),
).toThrow('Invalid offset');
expect(() =>
resolveReadRange(undefined, { offset: 1.5, byte_count: 10 }),
).toThrow('Invalid offset');
expect(() =>
resolveReadRange(undefined, { offset: 'abc', byte_count: 10 }),
).toThrow('Invalid offset');
});
it('throws 400 on non-decimal or unsafe numeric strings', () => {
for (const offset of [
'1e3',
'0x10',
' 4',
'1e21',
'9007199254740993',
]) {
expect(() => resolveReadRange(undefined, { offset })).toThrow(
'Invalid offset',
);
}
expect(() =>
resolveReadRange(undefined, {
offset: String(Number.MAX_SAFE_INTEGER),
byte_count: '3',
}),
).toThrow('Invalid byte_count');
});
it('throws 400 on non-positive or invalid byte_count', () => {
expect(() =>
resolveReadRange(undefined, { offset: 0, byte_count: 0 }),
).toThrow('Invalid byte_count');
expect(() =>
resolveReadRange(undefined, { offset: 0, byte_count: -5 }),
).toThrow('Invalid byte_count');
expect(() =>
resolveReadRange(undefined, { offset: 0, byte_count: 2.5 }),
).toThrow('Invalid byte_count');
expect(() =>
resolveReadRange(undefined, { offset: 0, byte_count: 'xyz' }),
).toThrow('Invalid byte_count');
});
});
describe('rangeForEntry', () => {
it('drops the range for an empty file only', () => {
const entry = (size: number) => ({ size }) as FSEntry;
expect(rangeForEntry(entry(0), 'bytes=0-511')).toBeUndefined();
expect(rangeForEntry(entry(10), 'bytes=0-4')).toBe('bytes=0-4');
});
});
describe('contextBoundStream', () => {
it("reads every chunk in the caller's context", async () => {
// A sink slower than the source that completes writes from outside
// any request, so later chunks are asked for from its drain.
const pending: Array<() => void> = [];
const flush = runWithContext({}, () =>
setInterval(() => pending.splice(0).forEach((done) => done()), 5),
);
const sink = new Writable({
highWaterMark: 1,
write(_chunk, _encoding, done) {
pending.push(done);
},
});
const seen: unknown[] = [];
try {
await runWithContext({ actor: 'caller' as never }, async () => {
async function* chunks() {
for (let i = 0; i < 40; i++) {
await new Promise((resolve) => setTimeout(resolve, 1));
seen.push(Context.get('actor'));
yield 'x';
}
}
await new Promise((resolve, reject) => {
contextBoundStream(chunks())
.pipe(sink)
.on('finish', resolve)
.on('error', reject);
});
});
} finally {
clearInterval(flush);
}
expect(seen).toHaveLength(40);
expect(seen.every((actor) => actor === 'caller')).toBe(true);
});
});
@@ -17,9 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { AsyncResource } from 'node:async_hooks';
import { posix as pathPosix } from 'node:path';
import { Readable } from 'node:stream';
import { contentType as contentTypeFromMime } from 'mime-types';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
@@ -101,80 +99,6 @@ export function getBoolean(
return undefined;
}
// Non-negative safe integer from a number or a plain digit string; anything
// else (exponents, hex, whitespace) is rejected.
function parseByteParam(raw: unknown, name: string, min: number): number {
const value =
typeof raw === 'number'
? raw
: typeof raw === 'string' && /^\d+$/.test(raw)
? Number(raw)
: NaN;
if (!Number.isSafeInteger(value) || value < min) {
throw new HttpError(400, `Invalid ${name}`, {
legacyCode: 'bad_request',
});
}
return value;
}
/**
* Resolves an HTTP Range header string from explicit `headersRange` or fallback
* `offset` / `byte_count` query parameters.
*/
export function resolveReadRange(
headersRange: unknown,
query: Record<string, unknown>,
): string | undefined {
if (typeof headersRange === 'string' && headersRange.length > 0) {
return headersRange;
}
const isSet = (v: unknown) => v !== undefined && v !== null && v !== '';
const offset = isSet(query.offset)
? parseByteParam(query.offset, 'offset', 0)
: undefined;
const byteCount = isSet(query.byte_count)
? parseByteParam(query.byte_count, 'byte_count', 1)
: undefined;
if (byteCount !== undefined) {
const last = (offset ?? 0) + byteCount - 1;
if (!Number.isSafeInteger(last)) {
throw new HttpError(400, 'Invalid byte_count', {
legacyCode: 'bad_request',
});
}
return `bytes=${offset ?? 0}-${last}`;
}
if (offset !== undefined) {
return `bytes=${offset}-`;
}
return undefined;
}
/**
* A response body read from `chunks` for `pipeToResponse`. Each chunk is pulled
* in the caller's async context, which share masking reads, not in whichever
* socket event asked for more.
*/
export function contextBoundStream(
chunks: AsyncGenerator<string, void, undefined>,
): Readable {
const next = AsyncResource.bind(() => chunks.next());
const stop = AsyncResource.bind(() => chunks.return(undefined));
return Readable.from({
[Symbol.asyncIterator]: () => ({ next, return: stop }),
});
}
/** No range is satisfiable on an empty file, so it reads whole: empty, 200. */
export function rangeForEntry(
entry: FSEntry,
range: string | undefined,
): string | undefined {
return entry.size === 0 ? undefined : range;
}
// Accepts either `{ path }` or `{ uid }` or `{ id }` or `{ parent, name }`
// from a legacy body field. Returns a resolved entry (throwing 404 if not
// found or 400 if no usable ref is present).
@@ -17,9 +17,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { Request, RequestHandler, Response } from 'express';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
@@ -469,63 +466,3 @@ describe('HomepageController GET /show/*splat', () => {
expect(html).toContain('/alice/Documents');
});
});
// ── /sw.js ──────────────────────────────────────────────────────────
describe('HomepageController GET /sw.js', () => {
const headersOf = (res: Response): Record<string, string> =>
Object.fromEntries(
(res.set as ReturnType<typeof vi.fn>).mock.calls.map(
([key, value]) => [String(key).toLowerCase(), String(value)],
),
);
it('serves the worker with root scope, as JavaScript, revalidated each time', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'puter-sw-route-'));
await mkdir(path.join(root, 'dist'));
await writeFile(path.join(root, 'dist', 'sw.js'), '// worker\n');
const config = server.services.homepage.config as {
gui_assets_root?: string;
env?: string;
};
const prev = { root: config.gui_assets_root, env: config.env };
config.gui_assets_root = root;
config.env = 'prod';
const { res, captured } = makeRes();
try {
await callRoute('get', '/sw.js', makeReq({ path: '/sw.js' }), res);
} finally {
config.gui_assets_root = prev.root;
config.env = prev.env;
}
expect(captured.statusCode).toBe(200);
expect(captured.body).toBe('// worker\n');
expect(headersOf(res)).toMatchObject({
'content-type': 'text/javascript; charset=utf-8',
'cache-control': 'no-cache',
'service-worker-allowed': '/',
});
});
it('is a 404 when there is no worker to serve', async () => {
const config = server.services.homepage.config as {
gui_assets_root?: string;
};
const prev = config.gui_assets_root;
config.gui_assets_root = path.join(tmpdir(), 'no-such-gui-root');
const { res, captured } = makeRes();
try {
await callRoute('get', '/sw.js', makeReq({ path: '/sw.js' }), res);
} finally {
config.gui_assets_root = prev;
}
expect(captured.statusCode).toBe(404);
});
it('is registered for the root subdomain only', () => {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === '/sw.js',
);
expect(route?.options.subdomain).toBe('');
});
});
@@ -198,20 +198,6 @@ export class HomepageController extends PuterController {
return sendShell(req, res, {}, launch);
});
// -- /sw.js - the service worker, scoped to the whole origin --
router.get('/sw.js', { subdomain: '' }, async (_req, res) => {
const source = await homepage.serviceWorkerSource();
if (source === null) {
res.status(404).send('Not found');
return;
}
res.set('Content-Type', 'text/javascript; charset=utf-8');
res.set('Cache-Control', 'no-cache');
res.set('Service-Worker-Allowed', '/');
res.send(source);
});
// -- Fallback static mount -----------------------------------
// Serves lingering GUI assets (images, fonts, lib files, etc.)
// out of <gui_assets_root>/src. Falls through to the 404 handler
@@ -198,7 +198,7 @@ describe('HostingController /delete-site', () => {
);
expect(emitSpy).toHaveBeenCalledWith(
'subdomain.delete',
{ subdomain: site.subdomain, uid: String(site.uuid), userId },
{ subdomain: site.subdomain, uid: String(site.uuid) },
{},
);
} finally {
+2 -2
View File
@@ -27,12 +27,12 @@ import { EventsController } from './events/EventsController.js';
import { FSController } from './fs/FSController.js';
import { HomepageController } from './homepage/HomepageController.js';
import { HostingController } from './hosting/HostingController.js';
import { KvShareController } from './kvShare/KvShareController.js';
import { LegacyFSController } from './fs/LegacyFSController.js';
import { NotificationController } from './notification/NotificationController.js';
import { OIDCController } from './oidc/OIDCController.js';
import { PuterAIController } from './puterai/PuterAIController.js';
import { ShareController } from './share/ShareController.js';
import { TeamController } from './team/TeamController.js';
import { StaticAssetsController } from './static/StaticAssetsController.js';
import { StaticPagesController } from './static/StaticPagesController.js';
import { SystemController } from './system/SystemController.js';
@@ -59,8 +59,8 @@ export const puterControllers = {
broadcast: BroadcastController,
notification: NotificationController,
events: EventsController,
kvShare: KvShareController,
share: ShareController,
team: TeamController,
webdav: WebDAVController,
oidc: OIDCController,
wisp: WispController,
File diff suppressed because it is too large. Load diff
@@ -1,272 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Request, Response } from 'express';
import type { Actor } from '../../core/actor.js';
import { Controller, Delete, Get, Post } from '../../core/http/decorators.js';
import { HttpError } from '../../core/http/HttpError.js';
import { bodyRecord } from '../../core/http/requestBody.js';
import {
assertShareableAppUid,
parseKvShareRecipient,
} from '../../services/kvShare/kvShares.js';
import type {
KvShareHandleListOptions,
KvShareOwnerListOptions,
KvShareOwnerListState,
} from '../../stores/events/KvShareHandleStore.js';
import {
KV_HANDLE_LIST_LIMIT_CAP,
KV_SHARE_OWNER_LIST_STATES,
} from '../../stores/events/KvShareHandleStore.js';
import { KV_GLOBAL_APP_KEY } from '../../stores/systemKv/SystemKVStore.js';
import { normalizeLimit } from '../../util/pagination.js';
import type { KvShareView } from '../../services/kvShare/KvShareService.js';
import { EVENTS_LIST_LIMIT, KV_SHARE_BATCH_MAX } from '../events/limits.js';
import { PuterController } from '../types.js';
/** The app-less namespace is no app, so the wire says `null` for it. */
const publicAppUid = (appUid: string): string | null =>
appUid === KV_GLOBAL_APP_KEY ? null : appUid;
/**
* The namespace a listing's `appUid` names, in the form responses use: an app
* uid, or `null` for the app-less namespace.
*/
const listAppUid = (value: unknown): string | undefined => {
if (value === undefined || value === '') return undefined;
if (typeof value !== 'string')
throw new HttpError(400, '`appUid` must be a string', {
legacyCode: 'bad_request',
});
return value === 'null' ? KV_GLOBAL_APP_KEY : assertShareableAppUid(value);
};
const badRequest = (message: string): HttpError =>
new HttpError(400, message, { legacyCode: 'bad_request' });
/** A share as `POST /kv/shares` reports it. */
const shareBody = (share: KvShareView) => ({
handle: share.handle,
prefix: share.prefix,
access: share.access,
appUid: publicAppUid(share.appUid),
holder: share.holderUsername,
label: share.label,
notify: share.notify,
createdAt: share.createdAt,
});
/** `recipients` when the body names it: 1 to `KV_SHARE_BATCH_MAX` entries. */
const batchRecipients = (
body: Record<string, unknown>,
): unknown[] | undefined => {
const { recipient, recipients } = body;
if (recipients === undefined) return undefined;
if (recipient !== undefined)
throw badRequest('Send either `recipient` or `recipients`, not both');
if (
!Array.isArray(recipients) ||
recipients.length === 0 ||
recipients.length > KV_SHARE_BATCH_MAX
)
throw badRequest(
`\`recipients\` must be an array of 1 to ${KV_SHARE_BATCH_MAX} recipients`,
);
return recipients;
};
const listState = (value: unknown): KvShareOwnerListState | undefined => {
if (value === undefined || value === '') return undefined;
if (!(KV_SHARE_OWNER_LIST_STATES as readonly unknown[]).includes(value))
throw new HttpError(400, '`state` must be `live`, `revoked` or `all`', {
legacyCode: 'bad_request',
});
return value as KvShareOwnerListState;
};
const pageOptions = (req: Request): KvShareHandleListOptions => {
const query = (req.query ?? {}) as Record<string, unknown>;
return {
limit: normalizeLimit(query.limit, { cap: KV_HANDLE_LIST_LIMIT_CAP }),
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
includeTotal: query.includeTotal === 'true',
appUid: listAppUid(query.appUid),
};
};
const ownerPageOptions = (req: Request): KvShareOwnerListOptions => ({
...pageOptions(req),
state: listState((req.query ?? {}).state),
});
/**
* Sharing a region of one account's key-value data with another account.
*
* Gated like the events surface: `subdomain: 'api'` plus `allowAccessToken`,
* with app-ness decided from `effectiveApp` in the service, which is where the
* owner, holder, delegation and namespace rules live. Only sharing asks for a
* confirmed email; listing, revoking and unsharing only narrow or report.
*/
@Controller('/kv')
export class KvShareController extends PuterController {
/**
* POST /kv/shares — share a region with another account, `read` unless the
* body asks otherwise. Sharing the same region with the same account again
* changes its level, label or `notify` flag in place. `recipients` shares
* it with several at once and reports each one on its own item.
*/
@Post('/shares', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
requireVerified: true,
})
async share(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const body = bodyRecord(req);
const fields = {
prefix: body.prefix,
access: body.access,
appUid: body.appUid,
label: body.label,
notify: body.notify,
};
const recipients = batchRecipients(body);
if (recipients) {
const items = await this.services.kvShare.mintKvHandles(
actor,
{ ...fields, recipients },
{ defaultAccess: 'read' },
);
res.json({
items: items.map((item) =>
item.share
? {
recipient: item.recipient,
...shareBody(item.share),
}
: {
recipient: item.recipient,
error: {
message: item.error?.message ?? 'Not shared',
code:
item.error?.legacyCode ??
item.error?.code ??
'internal_error',
},
},
),
});
return;
}
const share = await this.services.kvShare.mintKvHandle(
actor,
{ ...fields, recipient: parseKvShareRecipient(body.recipient) },
{ defaultAccess: 'read' },
);
res.json(shareBody(share));
}
/**
* GET /kv/shares — what this account has shared out, retired included
* unless `state` narrows it.
*/
@Get('/shares', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
rateLimit: EVENTS_LIST_LIMIT,
})
async listSharedByMe(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const page = await this.services.kvShare.listKvHandles(
actor,
ownerPageOptions(req),
);
res.json({
items: page.items.map((row) => ({
handle: row.handle,
prefix: row.prefix,
access: row.access,
appUid: publicAppUid(row.appUid),
holder: row.holderUsername,
label: row.label,
notify: row.notify,
createdAt: row.createdAt,
revokedAt: row.revokedAt,
revokedBy: row.revokedBy,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total !== undefined ? { total: page.total } : {}),
});
}
/** GET /kv/shares/with-me — live shares this account holds. */
@Get('/shares/with-me', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
rateLimit: EVENTS_LIST_LIMIT,
})
async listSharedWithMe(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const page = await this.services.kvShare.listKvSharesWithMe(
actor,
pageOptions(req),
);
res.json({
items: page.items.map((row) => ({
handle: row.handle,
access: row.access,
appUid: publicAppUid(row.appUid),
label: row.label,
notify: row.notify,
createdAt: row.createdAt,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total !== undefined ? { total: page.total } : {}),
});
}
/**
* DELETE /kv/shares/:handle — the owner revokes, the holder gives it up,
* and anyone else is told it is not there.
*/
@Delete('/shares/:handle', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
})
async unshare(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
res.json(
await this.services.kvShare.removeKvShare(actor, req.params.handle),
);
}
#requireActor(req: Request): Actor {
const actor = req.actor;
if (!actor?.user)
throw new HttpError(401, 'Unauthorized', {
legacyCode: 'unauthorized',
});
return actor;
}
}
@@ -21,9 +21,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
/**
* Route-level coverage for the notification endpoints the desktop calls over
* HTTP, so this suite is what catches a route going missing or a gate turning
* a legitimate dismiss or removal away.
* Route-level coverage for the notification endpoint. Dismissing is the only
* mark the desktop performs over HTTP, so this suite is what catches the route
* going missing or a gate turning a legitimate dismiss away.
*/
describe('notification endpoints over HTTP', () => {
let env: PuterTestEnv;
@@ -85,34 +85,4 @@ describe('notification endpoints over HTTP', () => {
});
expect(after?.acknowledged).toBeFalsy();
});
it('removes a notification', async () => {
const owner = env.users.user;
const { userId, uid } = await seedNotification(owner.username);
const res = await post('/notif/delete', owner.token, { uid });
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({});
const after = await env.server.stores.notification.getByUid(uid, {
userId,
});
expect(after).toBeNull();
});
it('refuses an anonymous removal', async () => {
const { userId, uid } = await seedNotification(env.users.other.username);
const res = await fetch(new URL('/notif/delete', env.apiOrigin), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ uid }),
});
expect(res.status).toBeGreaterThanOrEqual(400);
const after = await env.server.stores.notification.getByUid(uid, {
userId,
});
expect(after?.uid).toBe(uid);
});
});
@@ -183,74 +183,3 @@ describe('NotificationController.markAck', () => {
expect(after?.acknowledged).toBeFalsy();
});
});
// ── /notif/delete ───────────────────────────────────────────────────
describe('NotificationController.remove', () => {
it('deletes the underlying notification row', async () => {
const { actor, userId } = await makeUser();
const created = await server.stores.notification.create({
userId,
value: { title: 't' },
});
const { res, captured } = makeRes();
await controller.remove(
makeReq({ body: { uid: created.uid }, actor }),
res,
);
expect(captured.body).toEqual({});
const after = await server.stores.notification.getByUid(
created.uid as string,
{ userId },
);
expect(after).toBeNull();
});
it('succeeds for a uid that names no row', async () => {
const { actor } = await makeUser();
const { res, captured } = makeRes();
await controller.remove(
makeReq({ body: { uid: 'no-such-uid' }, actor }),
res,
);
expect(captured.body).toEqual({});
});
it('rejects a missing uid with 400', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
controller.remove(makeReq({ body: {}, actor }), res),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 401 when there is no actor on the request', async () => {
const { res } = makeRes();
await expect(
controller.remove(makeReq({ body: { uid: 'whatever' } }), res),
).rejects.toMatchObject({ statusCode: 401 });
});
it("does not delete another user's notification", async () => {
const a = await makeUser();
const b = await makeUser();
const created = await server.stores.notification.create({
userId: a.userId,
value: {},
});
const { res } = makeRes();
await controller.remove(
makeReq({ body: { uid: created.uid }, actor: b.actor }),
res,
);
const after = await server.stores.notification.getByUid(
created.uid as string,
{ userId: a.userId },
);
expect(after?.uid).toBe(created.uid);
});
});
@@ -20,44 +20,16 @@
import type { Request, Response } from 'express';
import { Controller, Post } from '../../core/http/decorators.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { NotificationService } from '../../services/notification/NotificationService.js';
import { PuterController } from '../types.js';
/** Both routes fire per interaction; the ceiling catches a loop, not a user. */
const MARK_ROUTE_OPTIONS = {
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: {
scope: 'notification-mark',
limit: 300,
window: 60_000,
key: 'user',
},
} as const;
/** The uid and owner a mark names, or the 400/401 the request earns. */
const markTarget = (req: Request): { uid: string; userId: number } => {
const uid = req.body?.uid;
if (typeof uid !== 'string' || uid.length === 0) {
throw new HttpError(400, '`uid` must be a non-empty string', {
legacyCode: 'bad_request',
});
}
const userId = req.actor?.user?.id;
if (!userId)
throw new HttpError(401, 'Unauthorized', {
legacyCode: 'unauthorized',
});
return { uid, userId };
};
/**
* GUI-facing notification endpoints. Supplement the `puter-notifications`
* driver (which handles reads via `/drivers/call`) with the mutations the puter
* desktop client calls directly.
* GUI-facing notification endpoint. Supplements the `puter-notifications`
* driver (which handles CRUD via `/drivers/call`) with the one mutation route
* the puter desktop client calls directly.
*
* Each goes through the NotificationService, which tells the user's other open
* tabs (`notif.ack`, `notif.removed`) so they update immediately.
* It emits `outer.gui.notif.ack` via the NotificationService so other open tabs
* for the same user see the state change immediately.
*/
@Controller('/notif')
export class NotificationController extends PuterController {
@@ -65,21 +37,50 @@ export class NotificationController extends PuterController {
* POST /notif/mark-ack — user dismissed a notification. Sets `acknowledged`
* timestamp; pushes ack event to sockets.
*/
@Post('/mark-ack', MARK_ROUTE_OPTIONS)
@Post('/mark-ack', {
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
// Fires per notification interaction, so the ceiling stays
// generous — it is here to catch a loop, not to pace a user.
rateLimit: {
scope: 'notification-mark',
limit: 300,
window: 60_000,
key: 'user',
},
})
async markAck(req: Request, res: Response): Promise<void> {
const { uid, userId } = markTarget(req);
await this.services.notification.markAcknowledged(uid, userId);
res.json({});
}
const uid = req.body?.uid;
if (typeof uid !== 'string' || uid.length === 0) {
throw new HttpError(400, '`uid` must be a non-empty string', {
legacyCode: 'bad_request',
});
}
const userId = req.actor?.user?.id;
if (!userId)
throw new HttpError(401, 'Unauthorized', {
legacyCode: 'unauthorized',
});
/**
* POST /notif/delete — user removed a notification from their history. A
* uid naming none of their rows succeeds too: it is gone either way.
*/
@Post('/delete', MARK_ROUTE_OPTIONS)
async remove(req: Request, res: Response): Promise<void> {
const { uid, userId } = markTarget(req);
await this.services.notification.remove(uid, userId);
const notifService = this.services.notification as unknown as
| NotificationService
| undefined;
if (notifService?.markAcknowledged) {
await notifService.markAcknowledged(uid, userId);
} else {
// Fallback: direct store call if service isn't wired
await (
this.stores as Record<string, unknown> as {
notification: {
markAcknowledged: (
uid: string,
userId: number,
) => Promise<boolean>;
};
}
).notification.markAcknowledged(uid, userId);
}
res.json({});
}
}
@@ -17,7 +17,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { installSeatStub } from '../../testFixtures/seatStub.js';
import type { Request, RequestHandler, Response } from 'express';
import {
afterAll,
@@ -234,14 +233,20 @@ const callRoute = async (
);
};
const oidc = (): OIDCService => server.services.oidc as unknown as OIDCService;
const oidc = (): OIDCService =>
server.services.oidc as unknown as OIDCService;
// ── GET /auth/oidc/providers ────────────────────────────────────────
describe('OIDCController GET /auth/oidc/providers', () => {
it('lists every provider whose config validates', async () => {
const { res, captured } = makeRes();
await callRoute('get', '/auth/oidc/providers', makeReq({}), res);
await callRoute(
'get',
'/auth/oidc/providers',
makeReq({}),
res,
);
// We configured a single `custom` provider; the live service
// walks the config and returns its id.
expect(captured.body).toEqual({ providers: ['custom'] });
@@ -412,9 +417,9 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
}),
res,
);
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
'state',
);
const state = new URL(
captured.redirectUrl ?? '',
).searchParams.get('state');
const decoded = oidc().verifyState(state!);
expect(String(decoded?.redirect_uri)).toBe(TEST_ORIGIN);
}
@@ -619,7 +624,10 @@ describe('OIDCController login callback', () => {
// The real OIDCService linked the new user — verify via the
// live store.
const linkedUser = await oidc().findUserByProviderSub('custom', sub);
const linkedUser = await oidc().findUserByProviderSub(
'custom',
sub,
);
expect(linkedUser).not.toBeNull();
expect(linkedUser?.email).toBe(email);
});
@@ -783,12 +791,14 @@ describe('OIDCController login callback', () => {
// Seed by running the linked-sub path first via createUserFromOIDC.
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `oidc-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
const firstUserId = created.user!.id;
@@ -828,12 +838,14 @@ describe('OIDCController login callback', () => {
it('redirects with auth_error when the user is suspended', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
// Flip the suspended bit on the existing row.
@@ -968,12 +980,14 @@ describe('OIDCController login callback', () => {
it('keeps an /app/<name> landing in the error redirect (suspended user)', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `sus-app-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
await server.stores.user.update(created.user!.id, { suspended: 1 });
@@ -1061,12 +1075,14 @@ describe('OIDCController login callback', () => {
it('uses popup-style error URL (msg_id + opener_origin) when the popup-state user is suspended', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `pop-sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
await server.stores.user.update(created.user!.id, { suspended: 1 });
@@ -1216,7 +1232,13 @@ describe('OIDCController login callback', () => {
email,
free_storage: 100 * 1024 * 1024,
});
installSeatStub(server as never, [seat.id]);
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
// Provisioning issues a temporary password once the seat exists.
await server.stores.user.update(seat.id, { password: 'hashed' });
return seat;
@@ -1312,12 +1334,14 @@ describe('OIDCController signup veto (abuse harness)', () => {
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
vetoWithTrail(email, 'trail-svc-1');
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
email,
email_verified: true,
}),
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
email,
email_verified: true,
}),
);
expect(created.success).toBe(false);
expect(created.code).toBe('signup_blocked');
@@ -1652,12 +1676,14 @@ describe('OIDCController signup callback', () => {
// resolves to `linked-sub` rather than creating a new user.
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `existing-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
@@ -1729,12 +1755,14 @@ describe('OIDCController signup callback', () => {
it('redirects suspended users to action=signup with message=account_suspended', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
await server.stores.user.update(created.user!.id, { suspended: 1 });
@@ -1871,12 +1899,14 @@ describe('OIDCController revalidate callback', () => {
// The linked account exists, but state.user_uuid points at someone else.
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
@@ -1908,12 +1938,14 @@ describe('OIDCController revalidate callback', () => {
it('happy path: sets the revalidation cookie and redirects to redirect-done', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
const userUuid = created.user!.uuid;
@@ -1960,12 +1992,14 @@ describe('OIDCController revalidate callback', () => {
it('falls back to /auth/revalidate-done when state.redirect_uri is cross-origin', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
const userUuid = created.user!.uuid;
@@ -2004,7 +2038,12 @@ describe('OIDCController revalidate callback', () => {
describe('OIDCController GET /auth/revalidate-done', () => {
it('renders the postMessage HTML body with the configured origin', async () => {
const { res, captured } = makeRes();
await callRoute('get', '/auth/revalidate-done', makeReq({}), res);
await callRoute(
'get',
'/auth/revalidate-done',
makeReq({}),
res,
);
expect(captured.contentType).toContain('text/html');
const body = String(captured.body);
expect(body).toContain('puter-revalidate-done');
@@ -2018,9 +2057,9 @@ describe('OIDCController GET /auth/revalidate-done', () => {
/**
* The proof exists because the popup return leg states two things the popup
* cannot check — the opener's origin and that a login completed — and a URL
* built from a verified `state` is byte-identical to one anybody can type. The
* opener's origin picks the app a token is minted for, so it has to be attested
* rather than read.
* built from a verified `state` is byte-identical to one anybody can type.
* The opener's origin picks the app a token is minted for, so it has to be
* attested rather than read.
*/
describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
const NONCE = 'binding-nonce';
+63 -72
View File
@@ -24,12 +24,6 @@ import { HttpError } from '../../core/http/HttpError.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterController } from '../types.js';
import { startWebSession } from '../auth/webSession.js';
import {
completeOidcLoginReturn,
mintOidcPopupReturn,
OIDC_POPUP_RETURN_COOKIE_NAME,
type OidcLoginContinuation,
} from '../auth/oidcLoginReturn.js';
import type { UserRow } from '../../stores/user/UserStore.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { normalizeBonusCode } from '../../util/signupBonus.js';
@@ -49,6 +43,11 @@ const REVALIDATION_EXPIRY_SEC = 300;
const OIDC_NONCE_COOKIE_NAME = 'puter_oidc_nonce';
const OIDC_NONCE_EXPIRY_SEC = 600;
// Single-use, and binds a popup-return proof to the browser that earned it.
// Expiry mirrors POPUP_RETURN_EXPIRY_SEC in OIDCService.
const OIDC_POPUP_RETURN_COOKIE_NAME = 'puter_oidc_popup_return';
const OIDC_POPUP_RETURN_EXPIRY_SEC = 300;
// The popup branch below hard-codes this return target.
const POPUP_RETURN_ACTION = 'sign-in';
@@ -285,6 +284,11 @@ function buildErrorRedirectUrl(
return `${base}${pagePath}?${params.toString()}`;
}
function appendQueryParam(url: string, key: string, value: string): string {
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}
/** Length-safe constant-time string compare (never throws on mismatch). */
function constantTimeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
@@ -799,9 +803,18 @@ if (window.opener) {
/** Sign a popup-return proof, bound to this browser and this popup. */
#mintPopupReturn(res: Response, payload: Record<string, unknown>): string {
return mintOidcPopupReturn(res, payload, {
config: this.config,
oidc: this.services.oidc,
const nonce = crypto.randomBytes(32).toString('base64url');
res.cookie(OIDC_POPUP_RETURN_COOKIE_NAME, nonce, {
// Redeemed same-origin.
...sessionCookieFlags(this.config, { crossSite: false }),
httpOnly: true,
maxAge: OIDC_POPUP_RETURN_EXPIRY_SEC * 1000,
path: '/',
});
return this.services.oidc.signPopupReturn({
action: POPUP_RETURN_ACTION,
...payload,
nonce,
});
}
@@ -859,7 +872,7 @@ if (window.opener) {
byEmail !== null &&
!byEmail.email_confirmed &&
userinfo.email_verified === true &&
(await this.services.team?.releaseUnconfirmedSeatEmail(
(await this.services.team.releaseUnconfirmedSeatEmail(
byEmail.id,
));
if (byEmail && !releasedFromSeat) {
@@ -999,71 +1012,49 @@ if (window.opener) {
stateDecoded: Record<string, unknown>,
extraQueryParams?: Record<string, string>,
): Promise<void> {
const origin = (this.config.origin ?? '').replace(/\/$/, '');
const continuation: OidcLoginContinuation = {
userUuid: user.uuid,
redirectUri:
typeof stateDecoded.redirect_uri === 'string'
? stateDecoded.redirect_uri
: origin || '/',
embeddedInPopup: stateDecoded.embedded_in_popup === true,
openerOrigin:
typeof stateDecoded.opener_origin === 'string'
? stateDecoded.opener_origin
: null,
messageId:
stateDecoded.msg_id == null
? null
: String(stateDecoded.msg_id),
switchedToLogin: extraQueryParams?.oidc_switched === 'login',
};
if (user.otp_enabled) {
const challenge = this.services.token.sign(
'otp',
{
purpose: 'otp-login',
user_uid: user.uuid,
jti: crypto.randomUUID(),
oidcContinuation: continuation,
},
{ expiresIn: '5m' },
);
res.set('Cache-Control', 'no-store');
res.set('Referrer-Policy', 'no-referrer');
let retryUrl = `${origin}/action/login`;
if (continuation.embeddedInPopup) {
const retry = new URL(
buildErrorRedirectUrl(
origin,
'login',
'other',
'unauthorized',
stateDecoded,
undefined,
(payload) => this.#mintPopupReturn(res, payload),
),
);
retry.searchParams.delete('auth_error');
retry.searchParams.delete('message');
retryUrl = retry.href;
}
const fragment = new URLSearchParams({
token: challenge,
retryUrl,
});
res.redirect(302, `${origin}/action/oidc-2fa#${fragment}`);
return;
}
await startWebSession(req, res, user, {
config: this.config,
auth: this.services.auth,
});
res.redirect(
302,
completeOidcLoginReturn(res, user, continuation, {
config: this.config,
oidc: this.services.oidc,
}),
);
const origin = (this.config.origin ?? '').replace(/\/$/, '');
let target = (stateDecoded.redirect_uri as string) || origin || '/';
if (!isSameOrigin(target, origin)) {
target = origin || '/';
}
if (stateDecoded.embedded_in_popup) {
target = appendQueryParam(target, 'oidc_login', 'true');
// `opener_origin` and `oidc_login` reach the popup as bare query
// parameters, which say nothing about where they came from: the
// URL a verified state produces is byte-identical to one anybody
// can type. The popup treats the opener's origin as the app
// identity to mint a token for, so it needs the integrity this
// state already carries — re-signed here, at the one point where
// the round trip is known to have actually happened.
//
// `user_uuid` binds the proof to the account that just completed
// OIDC. Without it a proof from one login could be replayed in
// another signed-in browser to skip its account picker; the popup
// only honors `oidc_login` when this matches its current user.
target = appendQueryParam(
target,
'opener_state',
this.#mintPopupReturn(res, {
opener_origin: stateDecoded.opener_origin ?? null,
msg_id: stateDecoded.msg_id ?? null,
oidc_login: true,
user_uuid: user.uuid,
}),
);
}
if (extraQueryParams) {
for (const [k, v] of Object.entries(extraQueryParams)) {
if (v != null) target = appendQueryParam(target, k, v);
}
}
res.redirect(302, target);
}
}
@@ -1,323 +0,0 @@
import { TOTP } from 'otpauth';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import {
setupPuterTestEnv,
createTestUser,
type PuterTestEnv,
} from '../../testUtil.js';
import type { IConfig } from '../../types.js';
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
describe('OIDC account second-factor login', () => {
let env: PuterTestEnv;
let claims: { sub: string; email: string; email_verified: boolean };
beforeAll(async () => {
env = await setupPuterTestEnv({
cookie_name: 'puter_auth_token',
oidc: {
providers: {
custom: {
client_id: 'test-client',
client_secret: 'test-secret',
authorization_endpoint: 'https://idp.example/authorize',
token_endpoint: 'https://idp.example/token',
userinfo_endpoint: 'https://idp.example/userinfo',
},
},
},
} as IConfig);
vi.spyOn(
env.server.services.oidc,
'exchangeCodeForTokens',
).mockResolvedValue({ access_token: 'test-access' } as never);
vi.spyOn(env.server.services.oidc, 'getUserInfo').mockImplementation(
async () => claims,
);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const account = async (enabled = true, linked = true) => {
const username = `oidc2fa-${uuidv4().slice(0, 8)}`;
await createTestUser(env.server, {
username,
password: 'oidc-test-password',
});
const user = (await env.server.stores.user.getByUsername(username))!;
const otp = new TOTP({
issuer: 'puter.com',
label: username,
algorithm: 'SHA1',
digits: 6,
});
claims = {
sub: uuidv4(),
email: `${username}@example.com`,
email_verified: true,
};
await env.server.stores.user.update(user.id, {
email: claims.email,
email_confirmed: true,
otp_enabled: enabled,
otp_secret: otp.secret.base32,
otp_recovery_codes: hashRecoveryCode('ABCDEFG2'),
});
if (linked) {
await env.server.services.oidc.linkProviderToUser(
user.id,
'custom',
claims,
);
}
return { user, otp };
};
const callback = async (
flow = 'login',
extra: Record<string, string> = {},
) => {
const start = new URL(
`/auth/oidc/custom/start?flow=${flow}`,
env.origin,
);
Object.entries(extra).forEach(([key, value]) =>
start.searchParams.set(key, value),
);
const begun = await fetch(start, { redirect: 'manual' });
expect(begun.status).toBe(302);
const state = new URL(begun.headers.get('location')!).searchParams.get(
'state',
)!;
const nonce = begun.headers
.getSetCookie()
.find((value) => value.startsWith('puter_oidc_nonce='))!
.split(';')[0]!;
return fetch(
new URL(
`/auth/oidc/callback/${flow}?code=test-code&state=${encodeURIComponent(state)}`,
env.origin,
),
{ redirect: 'manual', headers: { cookie: nonce } },
);
};
const verify = (token: string, code: string, recovery = false) =>
fetch(
new URL(
recovery ? '/login/recovery-code' : '/login/otp',
env.origin,
),
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token, code }),
},
);
const challenge = (response: Response) => {
expect(response.status).toBe(302);
expect(
response.headers
.getSetCookie()
.some((value) => value.startsWith('puter_auth_token=')),
).toBe(false);
const location = new URL(response.headers.get('location')!);
expect(location.pathname).toBe('/action/oidc-2fa');
const token = new URLSearchParams(location.hash.slice(1)).get('token');
expect(token).toBeTruthy();
return token!;
};
it.each(['login', 'signup'])(
'requires the authenticator before completing existing-account %s',
async (flow) => {
const { otp } = await account();
const token = challenge(
await callback(flow, { return_to: '/dashboard' }),
);
const response = await verify(token, otp.generate());
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
proceed: true,
next_step: 'complete',
redirectUrl: `${env.origin}/dashboard${flow === 'signup' ? '?oidc_switched=login' : ''}`,
});
expect(
response.headers
.getSetCookie()
.some((value) => value.startsWith('puter_auth_token=')),
).toBe(true);
expect((await verify(token, otp.generate())).status).toBe(400);
},
);
it('incorrect codes do not start a session and allow a valid recovery-code retry', async () => {
const { otp } = await account();
const token = challenge(await callback());
const wrong = ['000000', '000001', '000002'].find(
(code) => otp.validate({ token: code }) === null,
)!;
const rejected = await verify(token, wrong);
expect(await rejected.json()).toMatchObject({ proceed: false });
expect(rejected.headers.getSetCookie()).toHaveLength(0);
const accepted = await verify(token, 'ABCDEFG2', true);
expect(accepted.status).toBe(200);
expect(await accepted.json()).toMatchObject({
proceed: true,
redirectUrl: env.origin,
});
});
it('preserves an unauthenticated popup retry and attests login only after verification', async () => {
const { otp, user } = await account();
const response = await callback('login', {
embedded_in_popup: 'true',
msg_id: 'message-123',
opener_origin: 'https://app.example',
});
const token = challenge(response);
const fragment = new URLSearchParams(
new URL(response.headers.get('location')!).hash.slice(1),
);
const retry = new URL(fragment.get('retryUrl')!);
expect(retry.origin).toBe(env.origin);
expect(retry.searchParams.get('action')).toBe('login');
expect(retry.searchParams.get('embedded_in_popup')).toBe('true');
expect(retry.searchParams.get('msg_id')).toBe('message-123');
expect(retry.searchParams.has('auth_error')).toBe(false);
const retryNonce = response.headers
.get('set-cookie')!
.match(/puter_oidc_popup_return=([^;, ]+)/)![1];
const retried = await fetch(
new URL('/auth/oidc/verify-popup-return', env.origin),
{
method: 'POST',
headers: {
'content-type': 'application/json',
cookie: `puter_oidc_popup_return=${retryNonce}`,
},
body: JSON.stringify({
opener_state: retry.searchParams.get('opener_state'),
}),
},
);
expect(retried.status).toBe(200);
expect(await retried.json()).toMatchObject({
action: 'login',
oidc_login: false,
user_uuid: null,
msg_id: 'message-123',
opener_origin: 'https://app.example',
});
const accepted = await verify(token, otp.generate());
const data = await accepted.json();
const target = new URL(data.redirectUrl);
expect(target.pathname).toBe('/action/sign-in');
expect(target.searchParams.get('msg_id')).toBe('message-123');
expect(target.searchParams.get('oidc_login')).toBe('true');
const proof = env.server.services.oidc.verifyPopupReturn(
target.searchParams.get('opener_state')!,
);
expect(proof).toMatchObject({
user_uuid: user.uuid,
opener_origin: 'https://app.example',
msg_id: 'message-123',
oidc_login: true,
});
expect(
accepted.headers
.getSetCookie()
.some((value) => value.startsWith('puter_oidc_popup_return=')),
).toBe(true);
});
it('requires the authenticator when verified email first links an existing account', async () => {
const { otp } = await account(true, false);
const token = challenge(await callback());
expect((await verify(token, otp.generate())).status).toBe(200);
});
it('requires the authenticator for an OIDC-only account', async () => {
const { user, otp } = await account();
await env.server.stores.user.update(user.id, { password: null });
const token = challenge(await callback());
const response = await verify(token, otp.generate());
expect(await response.json()).toMatchObject({
proceed: true,
next_step: 'complete',
});
});
it('uses distinct short-lived challenges and refuses a modified token', async () => {
const { otp } = await account();
const first = challenge(await callback());
const second = challenge(await callback());
expect(first).not.toBe(second);
const decoded = env.server.services.token.verify<{
purpose: string;
jti: string;
exp: number;
iat: number;
}>('otp', first);
expect(decoded.purpose).toBe('otp-login');
expect(decoded.jti).toBeTruthy();
expect(decoded.exp - decoded.iat).toBe(300);
const modified = `${first.slice(0, -4)}abcd`;
const rejected = await verify(modified, otp.generate());
expect(rejected.status).toBe(400);
expect(rejected.headers.get('set-cookie')).toBeNull();
});
it('validates continuation account binding before creating any session', async () => {
const { otp } = await account();
const issued = challenge(await callback());
const decoded = env.server.services.token.verify<
Record<string, unknown>
>('otp', issued);
const continuation = decoded.oidcContinuation as Record<
string,
unknown
>;
const mismatched = env.server.services.token.sign('otp', {
...decoded,
jti: uuidv4(),
oidcContinuation: { ...continuation, userUuid: uuidv4() },
});
const rejected = await verify(mismatched, otp.generate());
expect(rejected.status).toBe(400);
expect(rejected.headers.get('set-cookie')).toBeNull();
});
it('clamps a signed continuation redirect to the configured origin', async () => {
const { otp } = await account();
const issued = challenge(await callback());
const decoded = env.server.services.token.verify<
Record<string, unknown>
>('otp', issued);
const continuation = decoded.oidcContinuation as Record<
string,
unknown
>;
const redirected = env.server.services.token.sign('otp', {
...decoded,
jti: uuidv4(),
oidcContinuation: {
...continuation,
redirectUri: 'https://elsewhere.example/',
},
});
const accepted = await verify(redirected, otp.generate());
expect(await accepted.json()).toMatchObject({
proceed: true,
redirectUrl: env.origin,
});
});
it('preserves ordinary OIDC login for accounts without 2FA', async () => {
await account(false);
const response = await callback('login', { return_to: '/desktop' });
expect(response.headers.get('location')).toBe(`${env.origin}/desktop`);
expect(
response.headers.get('set-cookie')?.includes('puter_auth_token='),
).toBe(true);
});
});
@@ -511,31 +511,6 @@ describe('PuterAIController.openaiChatCompletions', () => {
const completeArgs = completeSpy.mock.calls[0]![0];
expect(completeArgs.provider).toBe('openai-completion');
expect(completeArgs.fallback).toBeUndefined();
});
it('forwards fallback: false so the driver stops at the first route', async () => {
const completeSpy = stubChatComplete({
message: { role: 'assistant', content: 'hi there' },
finish_reason: 'stop',
usage: { prompt_tokens: 4, completion_tokens: 2 },
});
const { res } = makeRes();
await controller.openaiChatCompletions(
makeReq({
body: {
model: 'gpt-test',
provider: 'openai-completion',
fallback: false,
messages: [{ role: 'user', content: 'hi' }],
},
actor: makeUserActor(),
}),
res,
);
expect(completeSpy.mock.calls[0]![0].fallback).toBe(false);
});
it('still pins OpenAI when the caller names no model, so the default model does not move', async () => {
@@ -448,7 +448,6 @@ export class PuterAIController extends PuterController {
}
: {}),
...openaiCompatProvider(body),
...(body.fallback === false ? { fallback: false } : {}),
};
const result = await this.#complete(res, completeArgs);
@@ -546,7 +545,8 @@ export class PuterAIController extends PuterController {
}
} else if (ev.type === 'usage') {
usageDetails = ev.usageDetails as
UsageDetails | undefined;
| UsageDetails
| undefined;
finishReason = ev.finish_reason as string | undefined;
} else if (ev.type === 'error') {
res.write(
@@ -649,7 +649,6 @@ export class PuterAIController extends PuterController {
: {}),
...(finiteMaxTokens(body.max_tokens) ?? {}),
...openaiCompatProvider(body),
...(body.fallback === false ? { fallback: false } : {}),
};
const completionId = `cmpl-${randomId()}`;
@@ -695,7 +694,8 @@ export class PuterAIController extends PuterController {
sendChunk(ev.text);
} else if (ev.type === 'usage') {
usageDetails = ev.usageDetails as
UsageDetails | undefined;
| UsageDetails
| undefined;
finishReason = ev.finish_reason as string | undefined;
} else if (ev.type === 'error') {
res.write(
@@ -749,7 +749,8 @@ export class PuterAIController extends PuterController {
text: extractTextContent(
(
messageResult.message as
Record<string, unknown> | undefined
| Record<string, unknown>
| undefined
)?.content,
),
index: 0,
@@ -868,7 +869,6 @@ export class PuterAIController extends PuterController {
}
: {}),
provider: providerName,
...(body.fallback === false ? { fallback: false } : {}),
};
const responseId = generateId('resp');
@@ -324,18 +324,6 @@ describe('parseAnthropicRequest', () => {
expect(args.provider).toBe('claude');
expect(args.normalize).toBe(false);
expect(args.streamToolInput).toBe(true);
expect(args.fallback).toBeUndefined();
});
it('forwards fallback: false and nothing else for fallback', () => {
expect(
parseAnthropicRequest({ messages: [], fallback: false }, headers)
.fallback,
).toBe(false);
expect(
parseAnthropicRequest({ messages: [], fallback: 'no' }, headers)
.fallback,
).toBeUndefined();
});
it('maps thinking, tool_choice, stop_sequences, top_p/top_k', () => {
@@ -431,7 +431,6 @@ export const parseAnthropicRequest = (
: {}),
...(betas.length ? { anthropicBetas: betas } : {}),
provider: toStringOrEmpty(body.provider) || 'claude',
...(body.fallback === false ? { fallback: false } : {}),
};
// Silently ignored: metadata, service_tier, inference_geo, container,
// fallbacks, diagnostics.
@@ -151,12 +151,6 @@ describe('StaticAssetsController client_libs_root', () => {
const sentPutility = await callGetHandler(router, '/putility/v1');
expect(sentPutility).toEqual({ filename: 'putility.js/v1.js', root });
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
expect(findRoute(router, 'get', route)?.options.subdomain).toBe('js');
const sentWorker = await callGetHandler(router, route);
expect(sentWorker).toEqual({ filename: 'puter.js/push-sw.js', root });
}
});
it('does not register the client_libs routes when the root is unset', () => {
@@ -192,11 +186,6 @@ describe('StaticAssetsController puterjs_root', () => {
expect(v1.root).toBe(root);
const bareV1 = await callGetHandler(router, '/v1');
expect(bareV1.filename).toBe('puter.js');
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
const worker = await callGetHandler(router, route);
expect(worker).toEqual({ filename: 'push-sw.js', root });
}
});
it('serves puter.dev.js when present (dev webpack build)', async () => {
@@ -26,9 +26,9 @@ import type { PuterRouter } from '../../core/http/PuterRouter';
/**
* Static asset routes.
*
* /puter.js/v1, /puter.js/v2 → any subdomain /v1, /v2, /push-sw.js,
* /putility/v1 → js subdomain /sdk/* → root subdomain — puter-js bundle
* /dist/_, /src/_, /assets/* → root subdomain
* /puter.js/v1, /puter.js/v2 → any subdomain /v1, /v2, /putility/v1 → js
* subdomain /sdk/* → root subdomain — puter-js bundle /dist/_, /src/_,
* /assets/* → root subdomain
*
* Each block depends on its config root (`client_libs_root`, `gui_assets_root`,
* `puterjs_root`). When unset, that block is skipped — deployments that don't
@@ -55,12 +55,6 @@ export class StaticAssetsController extends PuterController {
router.get('/putility/v1', { subdomain: 'js' }, (_req, res) => {
res.sendFile('putility.js/v1.js', { root });
});
// `/v2/push-sw.js` is the path older docs gave.
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
router.get(route, { subdomain: 'js' }, (_req, res) => {
res.sendFile('puter.js/push-sw.js', { root });
});
}
}
// puter-js SDK mount. GUI loads it at `/sdk/puter.dev.js`; the
@@ -106,11 +100,6 @@ export class StaticAssetsController extends PuterController {
router.get('/v2', { subdomain: 'js' }, (_req, res) => {
res.sendFile(puterJsFile, { root: puterjsRoot });
});
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
router.get(route, { subdomain: 'js' }, (_req, res) => {
res.sendFile('push-sw.js', { root: puterjsRoot });
});
}
}
}
@@ -0,0 +1,978 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { makeActor } from '../../core/actor.js';
import type { IConfig } from '../../types';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
const randomHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`;
describe('team endpoints over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
// The cap has its own suite; these tests need many teams.
env = await setupPuterTestEnv({
teams_enabled: true,
max_teams_per_user: 100,
} as IConfig);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const call = (
method: string,
path: string,
token: string,
body?: unknown,
) =>
fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
/** An app token acting for `owner`, minted the way the dashboard does. */
const makeAppToken = async (owner: { username: string }) => {
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const actor = makeActor({ user: user! });
const app = await env.server.stores.app.create(
{
name: `team-http-app-${crypto.randomUUID()}`,
title: 'Team app',
index_url: `https://team-${crypto.randomUUID()}.test/`,
},
{ ownerUserId: actor.user.id! },
);
return env.server.services.auth.getUserAppToken(actor, app.uid);
};
/** A scoped (non-full-access) access token for `owner`, carrying no app. */
const makeScopedToken = async (owner: {
username: string;
token: string;
}) => {
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const anchor = `/${owner.username}/${crypto.randomUUID()}`;
await env.server.services.fs.mkdir(user!.id, {
path: anchor,
createMissingParents: true,
});
const entry = await env.server.stores.fsEntry.getEntryByPath(anchor);
const { actor } = await env.server.services.auth.authenticate(
owner.token,
);
return env.server.services.auth.createAccessToken(
actor!,
[[`fs:${entry!.uid}:list`]],
{ label: 'teams-scope' },
);
};
/** A team owned by `user`, with one provisioned member. */
const makeTeam = async () => {
const res = await call('POST', '/teams', env.users.user.token, {
name: 'Acme',
handle: randomHandle(),
});
// Unasserted, a broken create means tests run on `/teams/undefined`.
expect(res.status).toBe(200);
const team = (await res.json()) as { uid: string };
expect(team.uid).toBeTruthy();
const username = `http_${Math.random().toString(36).slice(2, 9)}`;
const member = await call(
'POST',
`/teams/${team.uid}/members`,
env.users.user.token,
{ username, email: `${username}@test.local` },
);
expect(member.status).toBe(200);
return { team, memberUsername: username };
};
// -- the owner-account gate --------------------------------------
it('creates a team and reports the caller as its owner', async () => {
const res = await call('POST', '/teams', env.users.user.token, {
name: 'Acme Design',
handle: randomHandle(),
});
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body).toMatchObject({ name: 'Acme Design', is_owner: true });
expect(body.uid).toMatch(/^[0-9a-f-]{36}$/u);
// `id` is internal and must not reach the wire.
expect(body).not.toHaveProperty('id');
});
it('returns 404, not 403, to a non-member', async () => {
const { team } = await makeTeam();
const res = await call(
'GET',
`/teams/${team.uid}`,
env.users.other.token,
);
// 403 would confirm the team exists.
expect(res.status).toBe(404);
});
it('refuses every administrative route to a member who is not the owner', async () => {
const { team, memberUsername } = await makeTeam();
// An activated seat: provisioning leaves the email unconfirmed, which
// `requireVerified` rejects, so an unactivated one cannot call at all.
const provisioned = await env.server.stores.user.getByUsername(
memberUsername,
);
await env.server.stores.user.update(provisioned!.id, {
email_confirmed: 1,
requires_email_confirmation: 0,
requires_password_change: 0,
});
const seat = await env.server.stores.user.getByUsername(memberUsername);
const { token } = await env.server.services.auth.createSessionToken(
seat!,
);
for (const [method, path] of [
['PUT', `/teams/${team.uid}`],
['DELETE', `/teams/${team.uid}`],
['POST', `/teams/${team.uid}/members`],
['GET', `/teams/${team.uid}/audit`],
] as const) {
const res = await call(
method,
path,
token,
method === 'GET' ? undefined : { name: 'nope' },
);
expect(res.status, `${method} ${path}`).toBe(403);
}
// Still a member, so reads it is entitled to still work.
const readable = await call('GET', `/teams/${team.uid}`, token);
expect(readable.status).toBe(200);
});
// -- the org_owned guard ------------------------------------------
it('refuses the team owner as the target of a member route', async () => {
const { team } = await makeTeam();
const res = await call(
'POST',
`/teams/${team.uid}/members/${env.users.user.username}/disable`,
env.users.user.token,
);
expect(res.status).toBe(404);
});
it('deletes a seat over HTTP only once it is disabled', async () => {
const { team, memberUsername } = await makeTeam();
const path = `/teams/${team.uid}/members/${memberUsername}`;
const live = await call('DELETE', path, env.users.user.token);
expect(live.status).toBe(409);
expect(await live.json()).toMatchObject({
code: 'account_must_be_disabled_first',
});
await call('POST', `${path}/disable`, env.users.user.token);
const gone = await call('DELETE', path, env.users.user.token);
expect(gone.status).toBe(200);
expect(
await env.server.stores.user.getByUsername(memberUsername),
).toBeFalsy();
});
// -- the directory ------------------------------------------------
it('keeps the directory shut until the team opts in', async () => {
const { team } = await makeTeam();
const before = await call(
'GET',
`/teams/${team.uid}/directory`,
env.users.user.token,
);
// 404, not 403: whether this is on is not an app's to probe for.
expect(before.status).toBe(404);
const on = await call('PUT', `/teams/${team.uid}`, env.users.user.token, {
directory_enabled: true,
});
expect(on.status).toBe(200);
expect(await on.json()).toMatchObject({ directory_enabled: true });
const after = await call(
'GET',
`/teams/${team.uid}/directory`,
env.users.user.token,
);
expect(after.status).toBe(200);
const body = (await after.json()) as { items: { username: string }[] };
expect(body.items.some((m) => m.username === env.users.user.username)).toBe(true);
// A scoped token was minted for one thing; a roster is not it.
const scoped = await makeScopedToken(env.users.user);
const narrow = await call(
'GET',
`/teams/${team.uid}/directory`,
typeof scoped === 'string' ? scoped : scoped.token,
);
expect(narrow.status).toBe(403);
});
it('refuses the directory to someone outside the team', async () => {
const { team } = await makeTeam();
await call('PUT', `/teams/${team.uid}`, env.users.user.token, {
directory_enabled: true,
});
const res = await call(
'GET',
`/teams/${team.uid}/directory`,
env.users.other.token,
);
expect(res.status).toBe(404);
});
// -- app actors, gated by the same opt-in as the directory --------
//
// Built through the service, not the wire: these routes share a
// rate-limit budget with the mutation tests above, and every HTTP
// create/update here would eat into that same window.
/** A team owned by `user`, created and toggled through the service. */
const makeServiceTeam = async () => {
const owner = (await env.server.stores.user.getByUsername(
env.users.user.username,
))!;
const team = await env.server.services.team.createTeam(owner.id, {
name: 'Acme',
handle: randomHandle(),
});
return { team, ownerId: owner.id };
};
it('omits a team from an app token until the directory opts in', async () => {
const { team, ownerId } = await makeServiceTeam();
const appToken = await makeAppToken(env.users.user);
const before = await call('GET', '/teams', appToken);
expect(before.status).toBe(200);
const beforeBody = (await before.json()) as { items: { uid: string }[] };
expect(beforeBody.items.some((t) => t.uid === team.uid)).toBe(false);
await env.server.services.team.updateTeam(team.uid, ownerId, {
directoryEnabled: true,
});
const after = await call('GET', '/teams', appToken);
expect(after.status).toBe(200);
const afterBody = (await after.json()) as { items: { uid: string }[] };
expect(afterBody.items.some((t) => t.uid === team.uid)).toBe(true);
});
it('does not count a team it hides from an app token', async () => {
const { team } = await makeServiceTeam();
const appToken = await makeAppToken(env.users.user);
const res = await call('GET', '/teams?includeTotal=true', appToken);
expect(res.status).toBe(200);
const body = (await res.json()) as {
items: { uid: string }[];
total: number;
};
expect(body.items.some((t) => t.uid === team.uid)).toBe(false);
// A count over rows the page withholds reports the hidden team.
expect(body.total).toBe(body.items.length);
});
it('still lists a team to its owner session with the directory off', async () => {
const { team } = await makeServiceTeam();
const res = await call('GET', '/teams', env.users.user.token);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: { uid: string }[] };
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
});
it('lists a team to a full-access API token with the directory off', async () => {
const { team } = await makeServiceTeam();
const res = await call('GET', '/teams', env.users.user.apiToken);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: { uid: string }[] };
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
});
it('404s /members to an app token until the directory opts in, then gives usernames only', async () => {
const { team, ownerId } = await makeServiceTeam();
const username = `svc_${Math.random().toString(36).slice(2, 9)}`;
await env.server.services.team.provisionAccount(team.uid, ownerId, {
username,
});
const appToken = await makeAppToken(env.users.user);
const before = await call(
'GET',
`/teams/${team.uid}/members`,
appToken,
);
expect(before.status).toBe(404);
expect(await before.json()).toMatchObject({ code: 'team_not_found' });
await env.server.services.team.updateTeam(team.uid, ownerId, {
directoryEnabled: true,
});
const after = await call(
'GET',
`/teams/${team.uid}/members`,
appToken,
);
expect(after.status).toBe(200);
const body = (await after.json()) as {
items: Record<string, unknown>[];
};
expect(body.items.length).toBeGreaterThan(0);
// An app gets username only — no org_owned, created_at, or uuid.
for (const m of body.items) expect(Object.keys(m)).toEqual(['username']);
});
it('still refuses an app token on GET /teams/:uid', async () => {
const { team } = await makeServiceTeam();
const appToken = await makeAppToken(env.users.user);
const res = await call('GET', `/teams/${team.uid}`, appToken);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'forbidden' });
});
it('excludes a never-activated or suspended seat from an app`s view of /members', async () => {
const { team, ownerId } = await makeServiceTeam();
const neverActivated = `svc_na_${Math.random().toString(36).slice(2, 9)}`;
await env.server.services.team.provisionAccount(team.uid, ownerId, {
username: neverActivated,
});
const suspendedUsername = `svc_su_${Math.random().toString(36).slice(2, 9)}`;
const suspended = await env.server.services.team.provisionAccount(
team.uid,
ownerId,
{ username: suspendedUsername },
);
await env.server.services.team.disableMember(
team.uid,
ownerId,
suspended.userId,
);
await env.server.services.team.updateTeam(team.uid, ownerId, {
directoryEnabled: true,
});
const appToken = await makeAppToken(env.users.user);
const appView = await call(
'GET',
`/teams/${team.uid}/members`,
appToken,
);
expect(appView.status).toBe(200);
const appItems = (
(await appView.json()) as { items: Record<string, unknown>[] }
).items;
for (const m of appItems) expect(Object.keys(m)).toEqual(['username']);
const appUsernames = appItems.map((m) => m.username);
expect(appUsernames).not.toContain(neverActivated);
expect(appUsernames).not.toContain(suspendedUsername);
// The owner's own seat is active, so it still shows.
expect(appUsernames).toContain(env.users.user.username);
// The owner's own session sees every seat regardless of status.
const ownerView = await call(
'GET',
`/teams/${team.uid}/members`,
env.users.user.token,
);
expect(ownerView.status).toBe(200);
const ownerUsernames = (
(await ownerView.json()) as { items: { username: string }[] }
).items.map((m) => m.username);
expect(ownerUsernames).toContain(neverActivated);
expect(ownerUsernames).toContain(suspendedUsername);
});
it('gives a full-access API token the owner`s uuid on /members', async () => {
const { team } = await makeServiceTeam();
const res = await call(
'GET',
`/teams/${team.uid}/members`,
env.users.user.apiToken,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
items: { username: string; uuid?: string }[];
};
const owner = body.items.find(
(m) => m.username === env.users.user.username,
);
expect(owner?.uuid).toEqual(expect.any(String));
});
it('refuses a scoped access token on GET /teams and /members', async () => {
const { team } = await makeServiceTeam();
const scoped = await makeScopedToken(env.users.user);
const teamsRes = await call('GET', '/teams', scoped);
expect(teamsRes.status).toBe(403);
expect(await teamsRes.json()).toMatchObject({ code: 'forbidden' });
const membersRes = await call(
'GET',
`/teams/${team.uid}/members`,
scoped,
);
expect(membersRes.status).toBe(403);
expect(await membersRes.json()).toMatchObject({ code: 'forbidden' });
});
it('lists members with org_owned distinguishing the owner', async () => {
const { team, memberUsername } = await makeTeam();
const res = await call(
'GET',
`/teams/${team.uid}/members`,
env.users.user.token,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
items: { username: string; org_owned: boolean }[];
};
const owner = body.items.find(
(m) => m.username === env.users.user.username,
);
const member = body.items.find((m) => m.username === memberUsername);
expect(owner?.org_owned).toBe(false);
expect(member?.org_owned).toBe(true);
});
it('gives seat uuids to the owner and to nobody else', async () => {
// Built through the service, not the wire: provisioning shares a
// rate-limit budget with the seat tests below, and one more HTTP
// provision here 429s them.
const owner = (await env.server.stores.user.getByUsername(
env.users.user.username,
))!;
const slug0 = Math.random().toString(36).slice(2, 9);
const team = await env.server.services.team.createTeam(owner.id, {
name: 'Acme',
handle: `uuidt-${slug0}`,
});
const memberUsername = `seat_${slug0}`;
await env.server.services.team.provisionAccount(team.uid, owner.id, {
username: memberUsername,
});
const owned = (await (
await call('GET', `/teams/${team.uid}/members`, env.users.user.token)
).json()) as { items: { username: string; uuid?: string }[] };
const seat = owned.items.find((m) => m.username === memberUsername);
// Billing keys a seat's plan on this, so the owner cannot act without it.
expect(seat?.uuid).toEqual(expect.any(String));
// A throwaway member, not the shared fixture: joining a team is
// permanent and would follow `other` into every later test.
const slug = Math.random().toString(36).slice(2, 9);
const joiner = await env.server.stores.user.create({
username: `joiner_${slug}`,
uuid: crypto.randomUUID(),
password: 'hashed',
email: `joiner_${slug}@test.local`,
});
await env.server.stores.team.addMember(team.uid, joiner.id, {
orgOwned: false,
});
const { token } =
await env.server.services.auth.createSessionToken(joiner);
const seen = (await (
await call('GET', `/teams/${team.uid}/members`, token)
).json()) as { items: { uuid?: string }[] };
expect(seen.items.length).toBeGreaterThan(0);
for (const m of seen.items) expect(m.uuid).toBeUndefined();
});
// -- provisioning over the wire -----------------------------------
it('never returns the activation link to the administrator', async () => {
const { team } = await makeTeam();
const username = `secret_${Math.random().toString(36).slice(2, 9)}`;
const res = await call(
'POST',
`/teams/${team.uid}/members`,
env.users.user.token,
{ username, email: `${username}@test.local` },
);
expect(res.status).toBe(200);
const raw = JSON.stringify(await res.json());
// Emailed to the member; returning it would let an admin use it.
expect(raw).not.toContain('set-new-password');
expect(raw).not.toContain('token');
});
it('refuses a taken username and offers alternatives', async () => {
const { team } = await makeTeam();
const res = await call(
'POST',
`/teams/${team.uid}/members`,
env.users.user.token,
{
username: env.users.other.username,
email: '[email protected]',
},
);
expect(res.status).toBe(409);
const body = (await res.json()) as {
suggestions?: string[];
fields?: { suggestions?: string[] };
};
const suggestions = body.suggestions ?? body.fields?.suggestions ?? [];
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions).not.toContain(env.users.other.username);
});
// -- audit --------------------------------------------------------
it('exposes the team audit to the team owner only', async () => {
const { team } = await makeTeam();
const mine = await call(
'GET',
`/teams/${team.uid}/audit`,
env.users.user.token,
);
expect(mine.status).toBe(200);
const body = (await mine.json()) as { items: { action: string }[] };
expect(body.items.map((e) => e.action)).toContain('provision');
const theirs = await call(
'GET',
`/teams/${team.uid}/audit`,
env.users.other.token,
);
expect(theirs.status).toBe(404);
});
// -- sharing with a team -------------------------------------
it('names the team it shared with instead of an empty recipient', async () => {
const { team } = await makeTeam();
const file = `/${env.users.user.username}/Documents/label-${Math.random()
.toString(36)
.slice(2, 8)}.txt`;
const written = await call('POST', '/fs/write', env.users.user.token, {
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
fileContent: 'abc',
});
expect(written.status).toBe(200);
const shared = await call('POST', '/share', env.users.user.token, {
recipients: [{ team: team.uid }],
items: [file],
mode: 'read',
});
expect(shared.status).toBe(200);
const body = (await shared.json()) as {
results: { recipient: string; status: string }[];
};
expect(body.results[0].status).toBe('success');
// Echoes the identifier the caller named; '' would leave a client with
// nothing to render or to match its request against.
expect(body.results[0].recipient).toBe(team.uid);
});
it('shares with two teams rather than collapsing them into one', async () => {
const second = await call('POST', '/teams', env.users.user.token, {
name: 'Second',
handle: randomHandle(),
});
expect(second.status).toBe(200);
const teamB = (await second.json()) as { uid: string };
const { team: teamA } = await makeTeam();
const file = `/${env.users.user.username}/Documents/two-${Math.random()
.toString(36)
.slice(2, 8)}.txt`;
const written = await call('POST', '/fs/write', env.users.user.token, {
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
fileContent: 'abc',
});
const fileUid = ((await written.json()) as { fsEntry: { uid: string } })
.fsEntry.uid;
const shared = await call('POST', '/share', env.users.user.token, {
recipients: [{ team: teamA.uid }, { team: teamB.uid }],
items: [file],
mode: 'read',
});
expect(shared.status).toBe(200);
const body = (await shared.json()) as {
results: { recipient: string; status: string }[];
};
expect(body.results).toHaveLength(2);
expect(body.results.every((r) => r.status === 'success')).toBe(true);
// The response shape alone proves nothing: `results` is built from the
// request's pairs, so a collapsed pair still reports two successes with
// the right labels. Only the shares that exist afterwards show it.
const mine = await call(
'GET',
'/share/shared-by-me?limit=100',
env.users.user.token,
);
expect(mine.status).toBe(200);
const listing = (await mine.json()) as {
items: { uid_entry?: string; holder_team?: { uid: string } }[];
};
const holders = listing.items
.filter((i) => i.uid_entry === fileUid)
.map((i) => i.holder_team?.uid)
.filter(Boolean)
.sort();
expect(holders).toEqual([teamA.uid, teamB.uid].sort());
});
it('names the team by handle when the caller shared by handle', async () => {
const handle = randomHandle();
const res = await call('POST', '/teams', env.users.user.token, {
name: 'Byhandle',
handle,
});
expect(res.status).toBe(200);
const file = `/${env.users.user.username}/Documents/handle-${Math.random()
.toString(36)
.slice(2, 8)}.txt`;
await call('POST', '/fs/write', env.users.user.token, {
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
fileContent: 'abc',
});
const shared = await call('POST', '/share', env.users.user.token, {
recipients: [{ teamHandle: handle }],
items: [file],
mode: 'read',
});
expect(shared.status).toBe(200);
const body = (await shared.json()) as {
results: { recipient: string }[];
};
expect(body.results[0].recipient).toBe(handle);
});
it('gives a non-member 404 on the member view, not an empty page', async () => {
const { team } = await makeTeam();
const res = await call(
'GET',
`/teams/${team.uid}/audit/me`,
env.users.other.token,
);
expect(res.status).toBe(404);
});
// -- the forced-change gate ---------------------------------------
/**
* A seat that has signed in on its temporary password. Email confirmation
* is cleared first so the gate under test is the one that answers.
*/
const signedInSeat = async (teamUid: string) => {
const username = `seat_${Math.random().toString(36).slice(2, 9)}`;
const res = await call(
'POST',
`/teams/${teamUid}/members`,
env.users.user.token,
{ username, email: `${username}@test.local` },
);
expect(res.status).toBe(200);
const { temporary_password: password } = (await res.json()) as {
temporary_password: string;
};
const row = (await env.server.stores.user.getByUsername(username))!;
await env.server.stores.user.update(row.id, {
requires_email_confirmation: false,
email_confirmed: true,
});
await env.server.stores.user.invalidateById(row.id);
const fresh = (await env.server.stores.user.getById(row.id))!;
const { token } = await env.server.services.auth.createSessionToken(
fresh,
{ user_agent: 'puter-test-seat' },
);
return { username, password, userId: row.id, token };
};
/**
* Cookie-credentialed on the GUI origin, as the user-protected gate insists.
* Sent with the seat's own user agent: the session touch otherwise rewrites
* it to Node's once a second boundary passes.
*/
const changePassword = (token: string, password: string, next: string) =>
fetch(new URL('/user-protected/change-password', env.origin), {
method: 'POST',
headers: {
'content-type': 'application/json',
'user-agent': 'puter-test-seat',
authorization: `Bearer ${token}`,
cookie: `puter_auth_token=${token}`,
},
body: JSON.stringify({ password, new_pass: next }),
});
it('signs the seat in but refuses every authenticated route after that', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
const res = await call('GET', '/teams', seat.token);
expect(res.status).toBe(403);
expect(await res.text()).toContain('password_change_required');
});
it('leaves change-password reachable, since it is what clears the gate', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
const changed = await changePassword(
seat.token,
seat.password,
'chosen-by-the-member',
);
expect(changed.status).toBe(200);
const row = await env.server.stores.user.getByProperty(
'id',
seat.userId,
{ force: true },
);
expect(Number(row?.requires_password_change)).toBe(0);
expect(row?.temp_password_expires_at ?? null).toBeNull();
});
it('admits the seat to the product once it has chosen a password', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
expect(
(await changePassword(seat.token, seat.password, 'my-own-password'))
.status,
).toBe(200);
// The session that changed the password is the one that survives.
const res = await call('GET', '/teams', seat.token);
expect(res.status).toBe(200);
});
it('closes re-issue behind a seat that has activated', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
await changePassword(seat.token, seat.password, 'a-password-of-mine');
const res = await call(
'POST',
`/teams/${team.uid}/members/${seat.username}/activation`,
env.users.user.token,
);
expect(res.status).toBe(409);
});
it('shows the member the reset and their own sign-in, and nothing else', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
await changePassword(seat.token, seat.password, 'chosen-once-already');
const reset = await call(
'POST',
`/teams/${team.uid}/members/${seat.username}/password-reset`,
env.users.user.token,
);
expect(reset.status).toBe(200);
const { temporary_password: issued } = (await reset.json()) as {
temporary_password: string;
};
const { token } = await env.server.services.auth.createSessionToken(
(await env.server.stores.user.getById(seat.userId))!,
{ user_agent: 'puter-test-seat' },
);
const mine = await call('GET', `/teams/${team.uid}/audit/me`, token);
// The seat owes a password change again, so its own view is all it reaches.
expect(mine.status).toBe(403);
const admin = await call(
'GET',
`/teams/${team.uid}/audit`,
env.users.user.token,
);
const body = (await admin.json()) as { items: { action: string }[] };
expect(body.items.map((e) => e.action)).toContain(
'reset_member_password',
);
expect(JSON.stringify(body)).not.toContain(issued);
});
it('shows an activated seat its own reset and sign-in over the wire', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
await changePassword(seat.token, seat.password, 'the-one-i-picked');
const res = await call('GET', `/teams/${team.uid}/audit/me`, seat.token);
expect(res.status).toBe(200);
const body = (await res.json()) as {
items: {
action: string;
username: string | null;
ip: string | null;
user_agent: string | null;
}[];
};
expect(body.items.map((e) => e.action)).toContain('activate');
const tell = body.items.find((e) => e.action === 'sign_in');
expect(tell?.username).toBe(seat.username);
expect(tell?.user_agent).toBe('puter-test-seat');
// Only their own; the team owner's entries are not theirs to read.
expect(
body.items.every((e) => e.username === seat.username),
).toBe(true);
});
it('refuses a temporary password that was never used in time', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
await env.server.stores.user.update(seat.userId, {
temp_password_expires_at: Math.floor(Date.now() / 1000) - 1,
});
await env.server.stores.user.invalidateById(seat.userId);
const res = await fetch(new URL('/login', env.origin), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
username: seat.username,
password: seat.password,
}),
});
expect(res.status).toBe(401);
expect(await res.text()).toContain('temporary_password_expired');
});
});
describe('team endpoints with teams_enabled off', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv({ teams_enabled: false } as IConfig);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
it('404s every team route, so no team code is reachable', async () => {
// Real team: 200 with the flag on, so a 404 means no route.
const owner = await env.server.stores.user.getByUsername(
env.users.user.username,
);
const team = await env.server.stores.team.create({
ownerUserId: owner!.id,
name: 'Unreachable',
handle: `off-${Math.random().toString(36).slice(2, 8)}`,
});
await env.server.stores.team.addMember(team.uid, owner!.id, {
orgOwned: false,
});
const paths: [string, string][] = [
['POST', '/teams'],
['GET', '/teams'],
['GET', `/teams/${team.uid}`],
['PUT', `/teams/${team.uid}`],
['DELETE', `/teams/${team.uid}`],
['GET', `/teams/${team.uid}/members`],
['POST', `/teams/${team.uid}/members`],
['GET', `/teams/${team.uid}/audit`],
['GET', `/teams/${team.uid}/audit/me`],
];
for (const [method, path] of paths) {
const res = await fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${env.users.user.token}`,
},
...(method === 'POST' || method === 'PUT'
? { body: JSON.stringify({ name: 'x' }) }
: {}),
});
expect(res.status, `${method} ${path}`).toBe(404);
// Ours would carry a team legacyCode; the framework's does not.
const body = await res.text();
expect(body, `${method} ${path}`).not.toContain('team_not_found');
expect(body, `${method} ${path}`).not.toContain(
'not_the_team_owner',
);
}
});
it('registers no team routes at all with the flag off', async () => {
// The controller exists and is constructed; only its routes are absent.
const controller = env.server.controllers.team as unknown as {
isEnabled?: () => boolean;
};
expect(controller).toBeTruthy();
expect(controller.isEnabled?.()).toBe(false);
});
it('leaves the schema inert rather than absent', async () => {
// The flag gates reachability, not DDL.
await expect(
env.server.stores.team.getByHandle('no-such-team'),
).resolves.toBeNull();
});
});
Loaded 100 of 903 files, more files were not shown because too many files have changed in this diff. Show more